using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Serialization.Models;
using StardewModdingAPI.Toolkit.Utilities;
namespace StardewModdingAPI.ModBuildConfig.Framework
{
/// Manages the files that are part of a mod package.
internal class ModFileManager
{
/*********
** Fields
*********/
/// The name of the manifest file.
private readonly string ManifestFileName = "manifest.json";
/// The files that are part of the package.
private readonly IDictionary Files;
/*********
** Public methods
*********/
/// Construct an instance.
/// The folder containing the project files.
/// The folder containing the build output.
/// Custom regex patterns matching files to ignore when deploying or zipping the mod.
/// Whether to validate that required mod files like the manifest are present.
/// The mod package isn't valid.
public ModFileManager(string projectDir, string targetDir, Regex[] ignoreFilePatterns, bool validateRequiredModFiles)
{
this.Files = new Dictionary(StringComparer.OrdinalIgnoreCase);
// validate paths
if (!Directory.Exists(projectDir))
throw new UserErrorException("Could not create mod package because the project folder wasn't found.");
if (!Directory.Exists(targetDir))
throw new UserErrorException("Could not create mod package because no build output was found.");
// collect files
foreach (Tuple entry in this.GetPossibleFiles(projectDir, targetDir))
{
string relativePath = entry.Item1;
FileInfo file = entry.Item2;
if (!this.ShouldIgnore(file, relativePath, ignoreFilePatterns))
this.Files[relativePath] = file;
}
// check for required files
if (validateRequiredModFiles)
{
// manifest
if (!this.Files.ContainsKey(this.ManifestFileName))
throw new UserErrorException($"Could not create mod package because no {this.ManifestFileName} was found in the project or build output.");
// DLL
// ReSharper disable once SimplifyLinqExpression
if (!this.Files.Any(p => !p.Key.EndsWith(".dll")))
throw new UserErrorException("Could not create mod package because no .dll file was found in the project or build output.");
}
}
/// Get the files in the mod package.
public IDictionary GetFiles()
{
return new Dictionary(this.Files, StringComparer.OrdinalIgnoreCase);
}
/// Get a semantic version from the mod manifest.
/// The manifest is missing or invalid.
public string GetManifestVersion()
{
if (!this.Files.TryGetValue(this.ManifestFileName, out FileInfo manifestFile) || !new JsonHelper().ReadJsonFileIfExists(manifestFile.FullName, out Manifest manifest))
throw new InvalidOperationException($"The mod does not have a {this.ManifestFileName} file."); // shouldn't happen since we validate in constructor
return manifest.Version.ToString();
}
/*********
** Private methods
*********/
/// Get all files to include in the mod folder, not accounting for ignore patterns.
/// The folder containing the project files.
/// The folder containing the build output.
/// Returns tuples containing the relative path within the mod folder, and the file to copy to it.
private IEnumerable> GetPossibleFiles(string projectDir, string targetDir)
{
// project manifest
bool hasProjectManifest = false;
{
FileInfo manifest = new FileInfo(Path.Combine(projectDir, this.ManifestFileName));
if (manifest.Exists)
{
yield return Tuple.Create(this.ManifestFileName, manifest);
hasProjectManifest = true;
}
}
// project i18n files
bool hasProjectTranslations = false;
DirectoryInfo translationsFolder = new DirectoryInfo(Path.Combine(projectDir, "i18n"));
if (translationsFolder.Exists)
{
foreach (FileInfo file in translationsFolder.EnumerateFiles())
yield return Tuple.Create(Path.Combine("i18n", file.Name), file);
hasProjectTranslations = true;
}
// project assets folder
bool hasAssetsFolder = false;
DirectoryInfo assetsFolder = new DirectoryInfo(Path.Combine(projectDir, "assets"));
if (assetsFolder.Exists)
{
foreach (FileInfo file in assetsFolder.EnumerateFiles("*", SearchOption.AllDirectories))
{
string relativePath = PathUtilities.GetRelativePath(projectDir, file.FullName);
yield return Tuple.Create(relativePath, file);
}
hasAssetsFolder = true;
}
// build output
DirectoryInfo buildFolder = new DirectoryInfo(targetDir);
foreach (FileInfo file in buildFolder.EnumerateFiles("*", SearchOption.AllDirectories))
{
// get path info
string relativePath = PathUtilities.GetRelativePath(buildFolder.FullName, file.FullName);
string[] segments = PathUtilities.GetSegments(relativePath);
// prefer project manifest/i18n/assets files
if (hasProjectManifest && this.EqualsInvariant(relativePath, this.ManifestFileName))
continue;
if (hasProjectTranslations && this.EqualsInvariant(segments[0], "i18n"))
continue;
if (hasAssetsFolder && this.EqualsInvariant(segments[0], "assets"))
continue;
// add file
yield return Tuple.Create(relativePath, file);
}
}
/// Get whether a build output file should be ignored.
/// The file to check.
/// The file's relative path in the package.
/// Custom regex patterns matching files to ignore when deploying or zipping the mod.
private bool ShouldIgnore(FileInfo file, string relativePath, Regex[] ignoreFilePatterns)
{
return
// release zips
this.EqualsInvariant(file.Extension, ".zip")
// Harmony (bundled into SMAPI)
|| this.EqualsInvariant(file.Name, "0Harmony.dll")
// Json.NET (bundled into SMAPI)
|| this.EqualsInvariant(file.Name, "Newtonsoft.Json.dll")
|| this.EqualsInvariant(file.Name, "Newtonsoft.Json.pdb")
|| this.EqualsInvariant(file.Name, "Newtonsoft.Json.xml")
// mod translation class builder (not used at runtime)
|| this.EqualsInvariant(file.Name, "Pathoschild.Stardew.ModTranslationClassBuilder.dll")
|| this.EqualsInvariant(file.Name, "Pathoschild.Stardew.ModTranslationClassBuilder.pdb")
|| this.EqualsInvariant(file.Name, "Pathoschild.Stardew.ModTranslationClassBuilder.xml")
// code analysis files
|| file.Name.EndsWith(".CodeAnalysisLog.xml", StringComparison.OrdinalIgnoreCase)
|| file.Name.EndsWith(".lastcodeanalysissucceeded", StringComparison.OrdinalIgnoreCase)
// OS metadata files
|| this.EqualsInvariant(file.Name, ".DS_Store")
|| this.EqualsInvariant(file.Name, "Thumbs.db")
// custom ignore patterns
|| ignoreFilePatterns.Any(p => p.IsMatch(relativePath));
}
/// Get whether a string is equal to another case-insensitively.
/// The string value.
/// The string to compare with.
private bool EqualsInvariant(string str, string other)
{
if (str == null)
return other == null;
return str.Equals(other, StringComparison.OrdinalIgnoreCase);
}
}
}