diff --git a/AGENTS.md b/AGENTS.md index 374cef90..14ee8230 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,8 @@ Requirements, architecture, and technology choices are made fresh. - **`main`** — production. Enginseer (the injected modding runtime + launcher) is merged as the production seed; Magos Modificus is scaffolded (Phase 0: - .NET 10 + Avalonia 12 foundation — libraries are stubs). + .NET 10 + Avalonia 12 foundation — Profiles implemented in Phase 1; the other + libraries are stubs). - **`poc`** — historical proof-of-concept, reference only. Not built upon. - Development is branch + PR; no unreviewed merges to `main` (reviewed + covered + qa'd + CI green). @@ -65,7 +66,7 @@ magos-modificus/ Magos Modificus — the mod manager app (.NET 10 + Avalo general/ Magos.Modificus.General — cross-cutting infra (logging bootstrap, config loader, AddGeneral() DI ext) config/ Magos.Modificus.Config — the MagosConfig schema + defaults (POCO) - profiles/ Magos.Modificus.Profiles — stub (interfaces + AddProfiles()) + profiles/ Magos.Modificus.Profiles — profile data model, persistence, mods.lst generation integrations/ Magos.Modificus.Integrations — stub steam/ Magos.Modificus.Steam — stub enginseer-client/ Magos.Modificus.EnginseerClient — stub (the v1 launch façade) @@ -73,6 +74,7 @@ magos-modificus/ Magos Modificus — the mod manager app (.NET 10 + Avalo the Steam non-steam-shortcut target) tests/ Magos.Modificus.General.Tests/ xUnit tests for the general library + Magos.Modificus.Profiles.Tests/ xUnit tests for the profiles library docs/ architecture, poc (frozen), reference .github/workflows/ CI: mingw-build + msvc-build (Enginseer) + magos-build (Magos Modificus) .gitignore ignores enginseer/target, enginseer/bin, .NET bin/obj, build artifacts, _local/ @@ -132,9 +134,9 @@ dotnet run --project magos-modificus/ui --configuration Release # bare Avalo file/dir → defaults (first-run safe). - **Logging** is Serilog (console + file) bridged into `Microsoft.Extensions.Logging`; honors `Logging:Level` + `Logging:LogFile`. -- Library projects are **stubs** (interfaces + `Add()` only); real - implementations come in later phases. See - `docs/architecture/MAGOS-MODIFICUS.md`. +- Profiles is implemented (Phase 1); the other library projects are **stubs** + (interfaces + `Add()` only). Real implementations come in later + phases. See `docs/architecture/MAGOS-MODIFICUS.md`. - **CI** (`magos-build.yml`) is scoped to `magos-modificus/**` + the workflow file, matrixed on Windows + Ubuntu; gates on build + tests. diff --git a/README.md b/README.md index 24187be1..2245265e 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ Magos has two components: [`enginseer/README.md`](enginseer/README.md) for build + developer details. - **Magos Modificus** — the mod manager app (UI, staging, load order, profiles, dependency resolution). Phase 0 scaffold in place (.NET 10 + Avalonia 12 - foundation, DI + logging + config + a bare UI window); library behavior is - stubbed for now. See [`magos-modificus/README.md`](magos-modificus/README.md) - for developer/build details. + foundation, DI + logging + config + a bare UI window); the Profiles library + is implemented (Phase 1), other library behavior is still stubbed. See + [`magos-modificus/README.md`](magos-modificus/README.md) for developer/build + details. ## Getting started diff --git a/magos-modificus/README.md b/magos-modificus/README.md index 041170c3..feee074d 100644 --- a/magos-modificus/README.md +++ b/magos-modificus/README.md @@ -7,8 +7,9 @@ Steam), and the "Launch Darktide" button that invokes the Enginseer launcher. > **Status: Phase 0 scaffold.** The project layout, DI composition, structured > logging, global config schema/loader, and a bare UI window are in place. -> Library implementations come in later phases — the domain libraries are -> currently stubs (interfaces + DI registration only). Target architecture: +> Library implementations come in later phases — Profiles is implemented +> (Phase 1); the other domain libraries are currently stubs (interfaces + DI +> registration only). Target architecture: > [`../docs/architecture/MAGOS-MODIFICUS.md`](../docs/architecture/MAGOS-MODIFICUS.md). ## Tech stack @@ -32,13 +33,14 @@ magos-modificus/ ui/ Magos.Modificus.UI Avalonia executable + DI composition root general/ Magos.Modificus.General cross-cutting infra: logging, config loader, DI config/ Magos.Modificus.Config the MagosConfig schema + defaults (POCO) - profiles/ Magos.Modificus.Profiles stub + profiles/ Magos.Modificus.Profiles implemented (Phase 1) integrations/ Magos.Modificus.Integrations stub steam/ Magos.Modificus.Steam stub enginseer-client/ Magos.Modificus.EnginseerClient stub (launch façade) launcher/ Magos.Modificus.Launcher stub (slim Steam-shortcut launcher) tests/ Magos.Modificus.General.Tests/ xUnit tests for the general library + Magos.Modificus.Profiles.Tests/ xUnit tests for the profiles library ``` Each library exposes an `Add()` extension method on diff --git a/magos-modificus/magos-modificus.sln b/magos-modificus/magos-modificus.sln index 950cf1ae..dca93d2a 100644 --- a/magos-modificus/magos-modificus.sln +++ b/magos-modificus/magos-modificus.sln @@ -39,6 +39,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Magos.Modificus.General.Tests", "tests\Magos.Modificus.General.Tests\Magos.Modificus.General.Tests.csproj", "{A3D79190-AD1E-428E-A0B0-224AE2A5A2BF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Magos.Modificus.Profiles.Tests", "tests\Magos.Modificus.Profiles.Tests\Magos.Modificus.Profiles.Tests.csproj", "{B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -157,6 +159,18 @@ Global {A3D79190-AD1E-428E-A0B0-224AE2A5A2BF}.Release|x64.Build.0 = Release|Any CPU {A3D79190-AD1E-428E-A0B0-224AE2A5A2BF}.Release|x86.ActiveCfg = Release|Any CPU {A3D79190-AD1E-428E-A0B0-224AE2A5A2BF}.Release|x86.Build.0 = Release|Any CPU + {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Debug|x64.Build.0 = Debug|Any CPU + {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Debug|x86.Build.0 = Debug|Any CPU + {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Release|Any CPU.Build.0 = Release|Any CPU + {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Release|x64.ActiveCfg = Release|Any CPU + {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Release|x64.Build.0 = Release|Any CPU + {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Release|x86.ActiveCfg = Release|Any CPU + {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -171,5 +185,6 @@ Global {7B06B902-3D0E-4336-8B0C-ABDB967AF85F} = {740C5202-8AD2-6D86-0CA9-F95B1A2D697C} {5952A950-2AE3-43BC-8891-28D81B2B638E} = {FA674B5A-3394-926C-2B1E-70E5B00E4A5C} {A3D79190-AD1E-428E-A0B0-224AE2A5A2BF} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/magos-modificus/profiles/IProfileService.cs b/magos-modificus/profiles/IProfileService.cs new file mode 100644 index 00000000..e441208d --- /dev/null +++ b/magos-modificus/profiles/IProfileService.cs @@ -0,0 +1,88 @@ +namespace Magos.Modificus.Profiles; + +/// +/// Profile + per-profile mod-list management. Owns the profile data model, +/// its on-disk persistence, and the projection of the mod list into +/// mods.lst for the Enginseer runtime. +/// +/// +/// Phase 1 → Phase 2 stability: this interface is designed so the +/// storage implementation can swap (per-profile dirs → shared-first + staging) +/// without changing the surface: +/// +/// abstracts "give me the --mod-path" — +/// Phase 1 returns the per-profile mods/ dir; Phase 2 returns a staged +/// dir built from shared-first resolution. +/// will grow fields (version policy, source) but +/// the Phase 1 fields stay. +/// No storage details (paths, shared-vs-local) leak through the interface. +/// +/// +public interface IProfileService +{ + /// All known profiles, as lightweight summaries. + IReadOnlyList ListProfiles(); + + /// Loads the full profile (metadata + mod list). + /// is unknown. + Profile GetProfile(Guid id); + + /// + /// Creates a new profile: generates the id, scaffolds its directory + mod + /// root, and persists an empty profile.json. + /// + /// The newly-created profile. + Profile CreateProfile(string name); + + /// Renames the profile (display label only; id and dir are unchanged). + /// is unknown. + void RenameProfile(Guid id, string newName); + + /// Removes the profile entry and its entire on-disk directory tree. + /// is unknown. + void DeleteProfile(Guid id); + + /// The profile's mod list (in stored order, not load order). + /// is unknown. + IReadOnlyList GetModList(Guid id); + + /// + /// Reassigns so the profile's mods follow + /// . Mods not mentioned keep their relative + /// order, appended after the listed ones; names in the list that aren't in + /// the profile are ignored. No mods are added or removed. + /// + /// is unknown. + void SetModOrder(Guid id, IReadOnlyList modNamesInOrder); + + /// Toggles for a single mod. + /// + /// is unknown, or is not in the profile's list. + /// + void SetModEnabled(Guid id, string modName, bool enabled); + + /// + /// Adds a mod entry to the end of the list ( + /// = true). List entry only — does NOT fetch or install mod files. + /// Idempotent: adding a name already in the list is a no-op. + /// + /// is unknown. + void AddMod(Guid id, string modName); + + /// + /// Removes the mod entry and the mod's local files, if any. A missing local + /// copy for a listed mod is graceful (not a crash). + /// + /// + /// is unknown, or is not in the profile's list. + /// + void RemoveMod(Guid id, string modName); + + /// + /// Ensures the profile's mod root exists and writes mods.lst from the + /// current mod list (enabled mods, in ). + /// Idempotent. Returns the --mod-path to pass to the Enginseer launcher. + /// + /// is unknown. + string PrepareModRoot(Guid id); +} diff --git a/magos-modificus/profiles/Magos.Modificus.Profiles.csproj b/magos-modificus/profiles/Magos.Modificus.Profiles.csproj index 3170ab59..952fdc95 100644 --- a/magos-modificus/profiles/Magos.Modificus.Profiles.csproj +++ b/magos-modificus/profiles/Magos.Modificus.Profiles.csproj @@ -10,4 +10,8 @@ + + + + diff --git a/magos-modificus/profiles/ModListEntry.cs b/magos-modificus/profiles/ModListEntry.cs new file mode 100644 index 00000000..4d787925 --- /dev/null +++ b/magos-modificus/profiles/ModListEntry.cs @@ -0,0 +1,33 @@ +namespace Magos.Modificus.Profiles; + +/// +/// A single mod entry within a profile's mod list — the source of truth that +/// projects into mods.lst. +/// +/// +/// Immutable: all properties are init-only. Mutations go through the +/// methods, which rebuild the changed entry +/// (via with expressions) and persist — a consumer can't silently edit +/// an entry returned from and have it +/// look persisted when it isn't. +/// Phase 1 shape only. Version / source / policy fields land with +/// Integrations and Phase 2 (shared-first storage); the Phase 1 fields stay +/// stable when they arrive, so this type will grow but not break. +/// +public sealed record ModListEntry +{ + /// The mod folder name — the value written to mods.lst. + public string Name { get; init; } = string.Empty; + + /// + /// Whether the mod is active. Disabled mods are omitted from + /// mods.lst (enable-by-omission, per the loader contract). + /// + public bool Enabled { get; init; } + + /// + /// Position within the load order; lower loads first. + /// rather than the list index so partial reordering is stable. + /// + public int Order { get; init; } +} diff --git a/magos-modificus/profiles/Profile.cs b/magos-modificus/profiles/Profile.cs new file mode 100644 index 00000000..b42a6665 --- /dev/null +++ b/magos-modificus/profiles/Profile.cs @@ -0,0 +1,31 @@ +namespace Magos.Modificus.Profiles; + +/// +/// A Magos Modificus profile — a named, owned set of mods + load order. The +/// aggregate root persisted to <ProfilesBaseFolder>/<Id>/profile.json. +/// +/// +/// Identity is (a , stable across renames); +/// the on-disk directory is keyed by it. is a display label, +/// not unique and not used as a path. +/// +public sealed class Profile +{ + /// Stable identity; also the on-disk directory name. + public Guid Id { get; init; } + + /// Display name. Renamable via . + public string Name { get; set; } = string.Empty; + + /// When the profile was first created (UTC). + public DateTimeOffset CreatedAt { get; init; } + + /// + /// The profile's mods, in no particular storage order — load order comes + /// from each entry's . Exposed as a + /// of immutable entries: neither the list + /// nor its entries can be edited in place — changes go through the + /// methods, which rebuild + persist. + /// + public IReadOnlyList Mods { get; set; } = Array.Empty(); +} diff --git a/magos-modificus/profiles/ProfileModule.cs b/magos-modificus/profiles/ProfileModule.cs deleted file mode 100644 index ca4c17ac..00000000 --- a/magos-modificus/profiles/ProfileModule.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace Magos.Modificus.Profiles; - -/// -/// Profile + settings management (create / edit / remove / switch, per-profile -/// mod lists, shared-vs-diverged allocation). Stub — implemented in a later -/// phase. See docs/architecture/MAGOS-MODIFICUS.md. -/// -public interface IProfileService -{ -} - -internal sealed class ProfileService : IProfileService -{ -} - -/// DI registration for the Profiles library. -public static class ServiceCollectionExtensions -{ - /// Registers the Profiles library services. - public static IServiceCollection AddProfiles(this IServiceCollection services) - { - services.AddSingleton(); - return services; - } -} diff --git a/magos-modificus/profiles/ProfileService.cs b/magos-modificus/profiles/ProfileService.cs new file mode 100644 index 00000000..ff0f739c --- /dev/null +++ b/magos-modificus/profiles/ProfileService.cs @@ -0,0 +1,316 @@ +using System.Text; +using System.Text.Json; +using Magos.Modificus.Config; +using Microsoft.Extensions.Logging; + +namespace Magos.Modificus.Profiles; + +/// +/// Filesystem-backed . Each profile lives under +/// <ProfilesBaseFolder>/<guid>/ with this layout: +/// +/// +/// +/// <ProfilesBaseFolder>/ (auto-created on first run) +/// <guid>/ (profile dir; id-named) +/// profile.json (metadata + mod list — the source of truth) +/// mods/ (the profile's mod root — the --mod-path) +/// mods.lst (generated by PrepareModRoot; a projection) +/// <mod-folder>/ (a mod's files, when installed) +/// +/// +/// Registered as a singleton: the service holds no per-request state — all +/// state lives on disk, and (its only config source) +/// is itself a singleton. Concurrent writes to the same profile are not +/// coordinated in Phase 1 (single-UI-thread assumption); Phase 2's storage +/// rewrite revisits concurrency if needed. +/// +/// +internal sealed class ProfileService : IProfileService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + }; + + // mods.lst is UTF-8 without BOM (the Lua loader reads it line-by-line; a + // BOM would surface as a stray prefix on the first mod name). + private static readonly Encoding ModListEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + private readonly string _baseFolder; + private readonly ILogger _logger; + + public ProfileService(MagosConfig config, ILogger logger) + { + // ProfilesBaseFolder is non-null by MagosConfig contract (defaults to + // <app-data>/profiles). Directory.CreateDirectory is idempotent, so this + // makes every subsequent op first-run safe without each one re-checking. + _baseFolder = config.ProfilesBaseFolder; + _logger = logger; + Directory.CreateDirectory(_baseFolder); + } + + /// + public IReadOnlyList ListProfiles() + { + var summaries = new List(); + foreach (var dir in Directory.EnumerateDirectories(_baseFolder)) + { + var name = Path.GetFileName(dir); + if (!Guid.TryParse(name, out var id)) + { + _logger.LogDebug("Skipping non-profile directory under profiles base: {Dir}", dir); + continue; + } + + try + { + var profile = ReadProfileFile(dir); + summaries.Add(new ProfileSummary(id, profile.Name)); + } + catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) + { + // A single unreadable profile must not break listing the rest. + _logger.LogWarning(ex, "Skipping unreadable profile at {Dir}", dir); + } + } + + // Predictable order for the UI profile picker: sort by Name, ordinal + // (stable, so equal names keep enumeration order). + return summaries.OrderBy(s => s.Name, StringComparer.Ordinal).ToList(); + } + + /// + public Profile GetProfile(Guid id) + { + var dir = ProfileDir(id); + return ReadProfileFile(dir); // throws KeyNotFoundException via EnsureReadable + } + + /// + public Profile CreateProfile(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Profile name must not be null or whitespace.", nameof(name)); + } + + var profile = new Profile + { + Id = Guid.NewGuid(), + Name = name, + CreatedAt = DateTimeOffset.UtcNow, + Mods = Array.Empty(), + }; + + // Scaffold the profile dir + mod root before persisting so a crash + // between the two never leaves a profile.json without its mods/ dir. + Directory.CreateDirectory(ProfileDir(profile.Id)); + Directory.CreateDirectory(ModRootDir(profile.Id)); + WriteProfileFile(profile); + + _logger.LogInformation("Created profile {Id} ('{Name}')", profile.Id, profile.Name); + return profile; + } + + /// + public void RenameProfile(Guid id, string newName) + { + if (string.IsNullOrWhiteSpace(newName)) + { + throw new ArgumentException("Profile name must not be null or whitespace.", nameof(newName)); + } + + var profile = GetProfile(id); + var previous = profile.Name; + profile.Name = newName; + WriteProfileFile(profile); + + _logger.LogInformation("Renamed profile {Id} '{Previous}' -> '{Name}'", id, previous, newName); + } + + /// + public void DeleteProfile(Guid id) + { + var dir = ProfileDir(id); + if (!Directory.Exists(dir)) + { + throw UnknownProfile(id); + } + + Directory.Delete(dir, recursive: true); + _logger.LogInformation("Deleted profile {Id}", id); + } + + /// + public IReadOnlyList GetModList(Guid id) => GetProfile(id).Mods; + + /// + public void SetModOrder(Guid id, IReadOnlyList modNamesInOrder) + { + var profile = GetProfile(id); + var current = profile.Mods; + + // Index the desired order by name (first occurrence wins for dupes). + var desiredIndex = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < modNamesInOrder.Count; i++) + { + if (modNamesInOrder[i] is { Length: > 0 } n && !desiredIndex.ContainsKey(n)) + { + desiredIndex[n] = i; + } + } + + // Stable sort: listed mods by their desired position first, then + // unmentioned mods in their existing relative order. OrderBy is stable, + // so equal keys keep storage order. Rebuild (immutable entries) with + // renumbered Order. + profile.Mods = current + .OrderBy(m => desiredIndex.TryGetValue(m.Name, out var idx) ? idx : int.MaxValue) + .Select((m, i) => m with { Order = i }) + .ToList(); + WriteProfileFile(profile); + } + + /// + public void SetModEnabled(Guid id, string modName, bool enabled) + { + var profile = GetProfile(id); + _ = profile.Mods.FirstOrDefault(m => string.Equals(m.Name, modName, StringComparison.Ordinal)) + ?? throw UnknownMod(id, modName); + + // Rebuild (immutable entries): swap the matching entry for a copy with + // the new Enabled. Write-through persists the whole aggregate. + profile.Mods = profile.Mods + .Select(m => string.Equals(m.Name, modName, StringComparison.Ordinal) ? m with { Enabled = enabled } : m) + .ToList(); + WriteProfileFile(profile); + } + + /// + public void AddMod(Guid id, string modName) + { + if (string.IsNullOrWhiteSpace(modName)) + { + throw new ArgumentException("Mod name must not be null or whitespace.", nameof(modName)); + } + + var profile = GetProfile(id); + + // Idempotent: re-adding an existing mod is a no-op (keeps its order + + // enabled state). Prevents duplicate entries from re-entrancy. + if (profile.Mods.Any(m => string.Equals(m.Name, modName, StringComparison.Ordinal))) + { + return; + } + + var nextOrder = profile.Mods.Count == 0 ? 0 : profile.Mods.Max(m => m.Order) + 1; + profile.Mods = profile.Mods + .Append(new ModListEntry { Name = modName, Enabled = true, Order = nextOrder }) + .ToList(); + WriteProfileFile(profile); + } + + /// + public void RemoveMod(Guid id, string modName) + { + var profile = GetProfile(id); + var entry = profile.Mods.FirstOrDefault(m => string.Equals(m.Name, modName, StringComparison.Ordinal)) + ?? throw UnknownMod(id, modName); + + var updated = profile.Mods.Where(m => !string.Equals(m.Name, modName, StringComparison.Ordinal)).ToList(); + profile.Mods = updated; + WriteProfileFile(profile); + + // Best-effort directory cleanup: the mod's folder may legitimately not + // exist (never installed, or already removed). Graceful, never throws. + var modDir = Path.Combine(ModRootDir(id), modName); + if (Directory.Exists(modDir)) + { + try + { + Directory.Delete(modDir, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Could not remove mod directory for {Mod} on profile {Id}", modName, id); + } + } + } + + /// + public string PrepareModRoot(Guid id) + { + var profile = GetProfile(id); + var modRoot = ModRootDir(id); + Directory.CreateDirectory(modRoot); + + WriteModList(profile, modRoot); + return modRoot; + } + + // ---- mods.lst generation ------------------------------------------------ + + private void WriteModList(Profile profile, string modRoot) + { + // Enabled mods only, in Order (stable sort preserves stored sequence + // for tied Order values). Faithful to what's stored — no DMF-first + // enforcement, no auto-sort (those are higher-layer concerns). + var enabled = profile.Mods + .Where(m => m.Enabled) + .OrderBy(m => m.Order) + .Select(m => m.Name); + + var sb = new StringBuilder(); + foreach (var name in enabled) + { + sb.Append(name).Append('\n'); + } + + File.WriteAllText(ModListPath(modRoot), sb.ToString(), ModListEncoding); + } + + // ---- persistence helpers ------------------------------------------------ + + private Profile ReadProfileFile(string profileDir) + { + var file = ProfileFilePath(profileDir); + EnsureReadable(file, profileDir); + using var stream = File.OpenRead(file); + var profile = JsonSerializer.Deserialize(stream) ?? new Profile(); + + // System.Text.Json can leave a non-nullable property as null if the + // file explicitly carries null (e.g. a hand-edit). Coerce Mods so + // downstream enumeration never NRE. + profile.Mods ??= Array.Empty(); + + return profile; + } + + private static void EnsureReadable(string file, string profileDir) + { + if (!Directory.Exists(profileDir) || !File.Exists(file)) + { + throw new KeyNotFoundException($"No profile exists at '{profileDir}'."); + } + } + + private void WriteProfileFile(Profile profile) + { + var json = JsonSerializer.Serialize(profile, JsonOptions); + File.WriteAllText(ProfileFilePath(ProfileDir(profile.Id)), json, ModListEncoding); + } + + // ---- path helpers (all internal-only — never leak through the interface) -- + + private string ProfileDir(Guid id) => Path.Combine(_baseFolder, id.ToString()); + private static string ProfileFilePath(string profileDir) => Path.Combine(profileDir, "profile.json"); + private string ModRootDir(Guid id) => Path.Combine(ProfileDir(id), "mods"); + private static string ModListPath(string modRoot) => Path.Combine(modRoot, "mods.lst"); + + private static KeyNotFoundException UnknownProfile(Guid id) => + new($"No profile exists with id '{id}'."); + + private static KeyNotFoundException UnknownMod(Guid id, string modName) => + new($"Profile '{id}' has no mod named '{modName}'."); +} diff --git a/magos-modificus/profiles/ProfileSummary.cs b/magos-modificus/profiles/ProfileSummary.cs new file mode 100644 index 00000000..9beb6268 --- /dev/null +++ b/magos-modificus/profiles/ProfileSummary.cs @@ -0,0 +1,7 @@ +namespace Magos.Modificus.Profiles; + +/// +/// Lightweight projection of a for listing — just enough +/// to render a profile picker without loading every profile's full mod list. +/// +public sealed record ProfileSummary(Guid Id, string Name); diff --git a/magos-modificus/profiles/ServiceCollectionExtensions.cs b/magos-modificus/profiles/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..756e0f14 --- /dev/null +++ b/magos-modificus/profiles/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Magos.Modificus.Profiles; + +/// DI registration for the Profiles library. +public static class ServiceCollectionExtensions +{ + /// + /// Registers . + /// Resolves MagosConfig + ILogger<ProfileService> from the + /// container (both provided by AddGeneral() / AddLogging()). + /// + public static IServiceCollection AddProfiles(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/magos-modificus/tests/Magos.Modificus.Profiles.Tests/Magos.Modificus.Profiles.Tests.csproj b/magos-modificus/tests/Magos.Modificus.Profiles.Tests/Magos.Modificus.Profiles.Tests.csproj new file mode 100644 index 00000000..20b7f451 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Profiles.Tests/Magos.Modificus.Profiles.Tests.csproj @@ -0,0 +1,30 @@ + + + + false + true + Magos.Modificus.Profiles.Tests + Magos.Modificus.Profiles.Tests + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/magos-modificus/tests/Magos.Modificus.Profiles.Tests/ModListTests.cs b/magos-modificus/tests/Magos.Modificus.Profiles.Tests/ModListTests.cs new file mode 100644 index 00000000..139bd9eb --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Profiles.Tests/ModListTests.cs @@ -0,0 +1,194 @@ +namespace Magos.Modificus.Profiles.Tests; + +/// +/// Mod-list management on a known profile: , +/// , , +/// , . +/// +public sealed class ModListTests +{ + [Fact] + public void AddMod_appends_enabled_entry_and_persists() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + + fx.Service.AddMod(profile.Id, "DMF"); + + var mods = fx.Service.GetModList(profile.Id); + var entry = Assert.Single(mods); + Assert.Equal("DMF", entry.Name); + Assert.True(entry.Enabled); + Assert.Equal(0, entry.Order); + } + + [Fact] + public void AddMod_assigns_increasing_order_to_subsequent_adds() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + + fx.Service.AddMod(profile.Id, "DMF"); + fx.Service.AddMod(profile.Id, "ModB"); + fx.Service.AddMod(profile.Id, "ModC"); + + var mods = fx.Service.GetModList(profile.Id); + Assert.Equal([("DMF", 0), ("ModB", 1), ("ModC", 2)], + mods.Select(m => (m.Name, m.Order)).ToArray()); + } + + [Fact] + public void AddMod_is_idempotent_for_existing_name() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + fx.Service.AddMod(profile.Id, "DMF"); + + fx.Service.AddMod(profile.Id, "DMF"); // no-op + + var entry = Assert.Single(fx.Service.GetModList(profile.Id)); + Assert.Equal("DMF", entry.Name); + } + + [Fact] + public void AddMod_rejects_null_or_whitespace_name() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + + Assert.Throws(() => fx.Service.AddMod(profile.Id, "")); + Assert.Throws(() => fx.Service.AddMod(profile.Id, " ")); + Assert.Empty(fx.Service.GetModList(profile.Id)); + } + + [Fact] + public void AddMod_unknown_profile_throws_KeyNotFoundException() + { + using var fx = new ProfileServiceFixture(); + + Assert.Throws(() => fx.Service.AddMod(Guid.NewGuid(), "DMF")); + } + + [Fact] + public void RemoveMod_drops_entry_and_deletes_its_directory() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + fx.Service.AddMod(profile.Id, "DMF"); + // Simulate an installed mod directory under the mod root. + var modDir = Path.Combine(fx.ModRoot(profile.Id), "DMF"); + Directory.CreateDirectory(modDir); + File.WriteAllText(Path.Combine(modDir, "marker.txt"), "x"); + Assert.True(Directory.Exists(modDir)); + + fx.Service.RemoveMod(profile.Id, "DMF"); + + Assert.Empty(fx.Service.GetModList(profile.Id)); + Assert.False(Directory.Exists(modDir)); + } + + [Fact] + public void RemoveMod_is_graceful_when_mod_directory_was_never_installed() + { + // "missing mod dir for a listed mod -> graceful": the mod is in the + // list (so RemoveMod removes it) but its folder was never created. + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + fx.Service.AddMod(profile.Id, "DMF"); + Assert.False(Directory.Exists(Path.Combine(fx.ModRoot(profile.Id), "DMF"))); + + fx.Service.RemoveMod(profile.Id, "DMF"); // must not throw + + Assert.Empty(fx.Service.GetModList(profile.Id)); + } + + [Fact] + public void RemoveMod_unknown_mod_throws_KeyNotFoundException() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + fx.Service.AddMod(profile.Id, "DMF"); + + Assert.Throws(() => fx.Service.RemoveMod(profile.Id, "NotThere")); + } + + [Fact] + public void SetModEnabled_toggles_state_and_persists() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + fx.Service.AddMod(profile.Id, "DMF"); + + fx.Service.SetModEnabled(profile.Id, "DMF", enabled: false); + + var entry = Assert.Single(fx.Service.GetModList(profile.Id)); + Assert.False(entry.Enabled); + + fx.Service.SetModEnabled(profile.Id, "DMF", enabled: true); + Assert.True(Assert.Single(fx.Service.GetModList(profile.Id)).Enabled); + } + + [Fact] + public void SetModEnabled_unknown_mod_throws_KeyNotFoundException() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + + Assert.Throws(() => fx.Service.SetModEnabled(profile.Id, "Ghost", true)); + } + + [Fact] + public void SetModOrder_reorders_mods_by_name_sequence() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + fx.Service.AddMod(profile.Id, "DMF"); // order 0 + fx.Service.AddMod(profile.Id, "ModB"); // order 1 + fx.Service.AddMod(profile.Id, "ModC"); // order 2 + + fx.Service.SetModOrder(profile.Id, ["ModC", "DMF", "ModB"]); + + var mods = fx.Service.GetModList(profile.Id); + Assert.Equal([("ModC", 0), ("DMF", 1), ("ModB", 2)], + mods.Select(m => (m.Name, m.Order)).ToArray()); + } + + [Fact] + public void SetModOrder_appends_unmentioned_mods_in_their_relative_order() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + fx.Service.AddMod(profile.Id, "DMF"); + fx.Service.AddMod(profile.Id, "ModB"); + fx.Service.AddMod(profile.Id, "ModC"); + + // Only DMF is mentioned; ModB + ModC keep their order, after DMF. + fx.Service.SetModOrder(profile.Id, ["DMF"]); + + var mods = fx.Service.GetModList(profile.Id); + Assert.Equal([("DMF", 0), ("ModB", 1), ("ModC", 2)], + mods.Select(m => (m.Name, m.Order)).ToArray()); + } + + [Fact] + public void SetModOrder_ignores_names_not_in_the_profile() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + fx.Service.AddMod(profile.Id, "DMF"); + + fx.Service.SetModOrder(profile.Id, ["DMF", "Imaginary", "AlsoImaginary"]); + + var mods = fx.Service.GetModList(profile.Id); + Assert.Single(mods); + Assert.Equal("DMF", mods[0].Name); + } + + [Fact] + public void SetModOrder_unknown_profile_throws_KeyNotFoundException() + { + using var fx = new ProfileServiceFixture(); + + Assert.Throws(() => fx.Service.SetModOrder(Guid.NewGuid(), ["DMF"])); + } +} diff --git a/magos-modificus/tests/Magos.Modificus.Profiles.Tests/PrepareModRootTests.cs b/magos-modificus/tests/Magos.Modificus.Profiles.Tests/PrepareModRootTests.cs new file mode 100644 index 00000000..fdbf9424 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Profiles.Tests/PrepareModRootTests.cs @@ -0,0 +1,173 @@ +using System.Text; + +namespace Magos.Modificus.Profiles.Tests; + +/// +/// + mods.lst generation +/// contract: enabled-only, honors , disabled +/// omitted, empty list -> empty file, UTF-8 no BOM, trailing newline, +/// idempotent, returns the --mod-path. +/// +public sealed class PrepareModRootTests +{ + [Fact] + public void Returns_mod_root_path_and_writes_mods_lst() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + fx.Service.AddMod(profile.Id, "DMF"); + + var modPath = fx.Service.PrepareModRoot(profile.Id); + + Assert.Equal(fx.ModRoot(profile.Id), modPath); + Assert.True(Directory.Exists(modPath)); + Assert.True(File.Exists(fx.ModsLst(profile.Id))); + } + + [Fact] + public void ModsLst_lists_enabled_mods_in_order_one_per_line_with_trailing_newline() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + fx.Service.AddMod(profile.Id, "DMF"); + fx.Service.AddMod(profile.Id, "ModB"); + fx.Service.AddMod(profile.Id, "ModC"); + // Reverse the order so we prove Order is honored, not insertion order. + fx.Service.SetModOrder(profile.Id, ["ModC", "DMF", "ModB"]); + + fx.Service.PrepareModRoot(profile.Id); + + Assert.Equal("ModC\nDMF\nModB\n", File.ReadAllText(fx.ModsLst(profile.Id))); + } + + [Fact] + public void ModsLst_omits_disabled_mods() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + fx.Service.AddMod(profile.Id, "DMF"); + fx.Service.AddMod(profile.Id, "DisabledMod"); + fx.Service.SetModEnabled(profile.Id, "DisabledMod", enabled: false); + + fx.Service.PrepareModRoot(profile.Id); + + Assert.Equal("DMF\n", File.ReadAllText(fx.ModsLst(profile.Id))); + } + + [Fact] + public void ModsLst_is_empty_file_when_no_enabled_mods() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); // no mods at all + + fx.Service.PrepareModRoot(profile.Id); + + Assert.True(File.Exists(fx.ModsLst(profile.Id))); + Assert.Equal(string.Empty, File.ReadAllText(fx.ModsLst(profile.Id))); + } + + [Fact] + public void ModsLst_is_empty_file_when_all_mods_disabled() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + fx.Service.AddMod(profile.Id, "DMF"); + fx.Service.SetModEnabled(profile.Id, "DMF", enabled: false); + + fx.Service.PrepareModRoot(profile.Id); + + Assert.Equal(string.Empty, File.ReadAllText(fx.ModsLst(profile.Id))); + } + + [Fact] + public void ModsLst_is_utf8_without_bom() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + fx.Service.AddMod(profile.Id, "DMF"); + + fx.Service.PrepareModRoot(profile.Id); + + var bytes = File.ReadAllBytes(fx.ModsLst(profile.Id)); + // A UTF-8 BOM would be 0xEF 0xBB 0xBF at the start. + Assert.False(bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF, + "mods.lst must not carry a UTF-8 BOM."); + Assert.Equal("DMF\n", Encoding.UTF8.GetString(bytes)); + } + + [Fact] + public void ModsLst_faithful_to_stored_entries_with_duplicate_names() + { + // Duplicate names can't arise through the public AddMod (it's + // idempotent), but generation must remain faithful + deterministic if + // the persisted list ever contains them — one line per entry, in Order. + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + fx.Service.AddMod(profile.Id, "DMF"); + fx.Service.AddMod(profile.Id, "ModB"); + // Hand-craft a profile.json with a duplicate entry, bypassing AddMod. + var dupProfile = new + { + Id = profile.Id, + Name = "P", + CreatedAt = profile.CreatedAt, + Mods = new[] + { + new { Name = "DMF", Enabled = true, Order = 0 }, + new { Name = "DMF", Enabled = true, Order = 1 }, + new { Name = "ModB", Enabled = true, Order = 2 }, + } + }; + File.WriteAllText(fx.ProfileJson(profile.Id), + System.Text.Json.JsonSerializer.Serialize(dupProfile), + new UTF8Encoding(false)); + + fx.Service.PrepareModRoot(profile.Id); + + // Faithful: both DMF entries are written, in Order. No dedup, no crash. + Assert.Equal("DMF\nDMF\nModB\n", File.ReadAllText(fx.ModsLst(profile.Id))); + } + + [Fact] + public void PrepareModRoot_is_idempotent_on_repeat_calls() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + fx.Service.AddMod(profile.Id, "DMF"); + fx.Service.AddMod(profile.Id, "ModB"); + + var first = fx.Service.PrepareModRoot(profile.Id); + var firstContent = File.ReadAllText(fx.ModsLst(profile.Id)); + var second = fx.Service.PrepareModRoot(profile.Id); + var secondContent = File.ReadAllText(fx.ModsLst(profile.Id)); + + Assert.Equal(first, second); + Assert.Equal(firstContent, secondContent); + Assert.Equal("DMF\nModB\n", secondContent); + } + + [Fact] + public void PrepareModRoot_reflects_latest_state_after_changes() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("P"); + fx.Service.AddMod(profile.Id, "DMF"); + fx.Service.PrepareModRoot(profile.Id); + Assert.Equal("DMF\n", File.ReadAllText(fx.ModsLst(profile.Id))); + + fx.Service.AddMod(profile.Id, "ModB"); + fx.Service.SetModEnabled(profile.Id, "DMF", enabled: false); + fx.Service.PrepareModRoot(profile.Id); + + // DMF now disabled -> omitted; ModB only. + Assert.Equal("ModB\n", File.ReadAllText(fx.ModsLst(profile.Id))); + } + + [Fact] + public void PrepareModRoot_unknown_profile_throws_KeyNotFoundException() + { + using var fx = new ProfileServiceFixture(); + + Assert.Throws(() => fx.Service.PrepareModRoot(Guid.NewGuid())); + } +} diff --git a/magos-modificus/tests/Magos.Modificus.Profiles.Tests/ProfileCrudTests.cs b/magos-modificus/tests/Magos.Modificus.Profiles.Tests/ProfileCrudTests.cs new file mode 100644 index 00000000..42df8972 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Profiles.Tests/ProfileCrudTests.cs @@ -0,0 +1,249 @@ +using Microsoft.Extensions.DependencyInjection; +using Magos.Modificus.Config; + +namespace Magos.Modificus.Profiles.Tests; + +/// +/// CRUD round-trip + first-run + edge cases for +/// profile lifecycle. Each test gets a fresh temp ProfilesBaseFolder. +/// +public sealed class ProfileCrudTests +{ + [Fact] + public void CreateProfile_scaffolds_dir_and_modroot_and_persists_profile_json() + { + using var fx = new ProfileServiceFixture(); + + var profile = fx.Service.CreateProfile("Vanilla+"); + + Assert.NotEqual(Guid.Empty, profile.Id); + Assert.Equal("Vanilla+", profile.Name); + Assert.Equal(DateTimeOffset.UtcNow, profile.CreatedAt, TimeSpan.FromSeconds(5)); + Assert.Empty(profile.Mods); + + // Dir + mod root + profile.json all created. + Assert.True(Directory.Exists(fx.ProfileDir(profile.Id))); + Assert.True(Directory.Exists(fx.ModRoot(profile.Id))); + Assert.True(File.Exists(fx.ProfileJson(profile.Id))); + } + + [Fact] + public void CreateProfile_rejects_null_or_whitespace_name() + { + using var fx = new ProfileServiceFixture(); + + Assert.Throws(() => fx.Service.CreateProfile("")); + Assert.Throws(() => fx.Service.CreateProfile(" ")); + } + + [Fact] + public void GetProfile_returns_persisted_profile_across_instances() + { + using var fx = new ProfileServiceFixture(); + var created = fx.Service.CreateProfile("My Profile"); + + // A second service instance reads the same disk state — proves the + // profile genuinely persists, not just in-memory. + var reloadConfig = MagosConfig.CreateDefault(); + reloadConfig.ProfilesBaseFolder = fx.BaseFolder; + using var reloadFx = new ReloadFixture(reloadConfig); + + var loaded = reloadFx.Service.GetProfile(created.Id); + + Assert.Equal(created.Id, loaded.Id); + Assert.Equal("My Profile", loaded.Name); + Assert.Equal(created.CreatedAt, loaded.CreatedAt); + Assert.Empty(loaded.Mods); + } + + [Fact] + public void GetProfile_unknown_id_throws_KeyNotFoundException() + { + using var fx = new ProfileServiceFixture(); + + Assert.Throws(() => fx.Service.GetProfile(Guid.NewGuid())); + } + + [Fact] + public void GetProfile_coerces_a_hand_edited_null_mods_to_empty() + { + using var fx = new ProfileServiceFixture(); + var created = fx.Service.CreateProfile("P"); + + // Simulate a hand-edit (or a future schema regression) writing Mods:null. + var id = created.Id.ToString(); + File.WriteAllText(fx.ProfileJson(created.Id), + $$"""{"Id":"{{id}}","Name":"P","CreatedAt":"2024-01-01T00:00:00Z","Mods":null}"""); + + var loaded = fx.Service.GetProfile(created.Id); + + Assert.NotNull(loaded.Mods); + Assert.Empty(loaded.Mods); // enumeration safe — no NRE downstream + } + + [Fact] + public void ListProfiles_returns_all_created_profiles_as_summaries() + { + using var fx = new ProfileServiceFixture(); + var a = fx.Service.CreateProfile("A"); + var b = fx.Service.CreateProfile("B"); + + var summaries = fx.Service.ListProfiles(); + + Assert.Equal(2, summaries.Count); + Assert.Contains(summaries, s => s is { Id: var id, Name: "A" } && id == a.Id); + Assert.Contains(summaries, s => s is { Id: var id, Name: "B" } && id == b.Id); + } + + [Fact] + public void ListProfiles_is_sorted_by_name_ordinal() + { + using var fx = new ProfileServiceFixture(); + fx.Service.CreateProfile("Charlie"); + fx.Service.CreateProfile("alpha"); + fx.Service.CreateProfile("Bravo"); + + var names = fx.Service.ListProfiles().Select(s => s.Name).ToArray(); + + // Ordinal sort: uppercase precedes lowercase in ASCII, so this order + // also distinguishes Ordinal from OrdinalIgnoreCase (which would put + // 'alpha' first). + Assert.Equal(new[] { "Bravo", "Charlie", "alpha" }, names); + } + + [Fact] + public void ListProfiles_is_empty_when_no_profiles_exist() + { + using var fx = new ProfileServiceFixture(); + + Assert.Empty(fx.Service.ListProfiles()); + } + + [Fact] + public void ListProfiles_skips_non_guid_and_corrupted_directories() + { + using var fx = new ProfileServiceFixture(); + fx.Service.CreateProfile("Good"); + + // A non-guid dir (ignored). + Directory.CreateDirectory(Path.Combine(fx.BaseFolder, "not-a-guid")); + // A guid dir with a corrupted profile.json (skipped, not fatal). + var badId = Guid.NewGuid(); + var badDir = Path.Combine(fx.BaseFolder, badId.ToString()); + Directory.CreateDirectory(badDir); + File.WriteAllText(Path.Combine(badDir, "profile.json"), "{ this is not json"); + + var summaries = fx.Service.ListProfiles(); + + var only = Assert.Single(summaries); + Assert.Equal("Good", only.Name); + } + + [Fact] + public void RenameProfile_updates_name_on_disk() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("Old"); + + fx.Service.RenameProfile(profile.Id, "New"); + + // The directory name is unchanged (id-keyed); only the name changed. + Assert.True(Directory.Exists(fx.ProfileDir(profile.Id))); + var reloaded = fx.Service.GetProfile(profile.Id); + Assert.Equal("New", reloaded.Name); + } + + [Fact] + public void RenameProfile_unknown_id_throws_KeyNotFoundException() + { + using var fx = new ProfileServiceFixture(); + + Assert.Throws(() => fx.Service.RenameProfile(Guid.NewGuid(), "X")); + } + + [Fact] + public void RenameProfile_rejects_null_or_whitespace_name() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("Old"); + + Assert.Throws(() => fx.Service.RenameProfile(profile.Id, "")); + Assert.Throws(() => fx.Service.RenameProfile(profile.Id, " ")); + // Name unchanged after the rejected renames. + Assert.Equal("Old", fx.Service.GetProfile(profile.Id).Name); + } + + [Fact] + public void DeleteProfile_removes_entry_and_directory_tree() + { + using var fx = new ProfileServiceFixture(); + var profile = fx.Service.CreateProfile("Doomed"); + fx.Service.AddMod(profile.Id, "SomeMod"); + var modPath = fx.Service.PrepareModRoot(profile.Id); // writes mods/ + mods.lst + populates + Assert.True(Directory.Exists(fx.ProfileDir(profile.Id))); + + fx.Service.DeleteProfile(profile.Id); + + Assert.False(Directory.Exists(fx.ProfileDir(profile.Id))); + Assert.Empty(fx.Service.ListProfiles()); + } + + [Fact] + public void DeleteProfile_unknown_id_throws_KeyNotFoundException() + { + using var fx = new ProfileServiceFixture(); + + Assert.Throws(() => fx.Service.DeleteProfile(Guid.NewGuid())); + } + + [Fact] + public void FirstRun_creates_missing_ProfilesBaseFolder() + { + // Point at a path whose parent chain does not exist yet; constructing + // the service (via AddProfiles DI) must create the full chain. + var tempRoot = Path.Combine(Path.GetTempPath(), "magos-firstrun-" + Guid.NewGuid()); + var baseFolder = Path.Combine(tempRoot, "deep", "profiles"); + Assert.False(Directory.Exists(baseFolder)); + + try + { + var config = MagosConfig.CreateDefault(); + config.ProfilesBaseFolder = baseFolder; + var services = new ServiceCollection(); + services.AddSingleton(config); + services.AddLogging(); + services.AddProfiles(); + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService(); // forces construction + + Assert.True(Directory.Exists(baseFolder)); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + /// Resolves a second against a given config. + private sealed class ReloadFixture : IDisposable + { + private readonly ServiceProvider _provider; + public IProfileService Service { get; } + + public ReloadFixture(MagosConfig config) + { + _provider = new ServiceCollection() + .AddSingleton(config) + .AddLogging() + .AddProfiles() + .BuildServiceProvider(); + Service = _provider.GetRequiredService(); + } + + public void Dispose() => _provider.Dispose(); + } +} diff --git a/magos-modificus/tests/Magos.Modificus.Profiles.Tests/ProfileServiceCollectionExtensionsTests.cs b/magos-modificus/tests/Magos.Modificus.Profiles.Tests/ProfileServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..f22b7a12 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Profiles.Tests/ProfileServiceCollectionExtensionsTests.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Magos.Modificus.Config; + +namespace Magos.Modificus.Profiles.Tests; + +/// +/// Proves AddProfiles() registers so it is +/// resolvable from DI given its dependencies (MagosConfig + logging). +/// +public sealed class ProfileServiceCollectionExtensionsTests +{ + [Fact] + public void AddProfiles_registers_resolvable_IProfileService() + { + var config = MagosConfig.CreateDefault(); + + var services = new ServiceCollection(); + services.AddSingleton(config); + services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning)); + services.AddProfiles(); + using var provider = services.BuildServiceProvider(); + + var service = provider.GetService(); + Assert.IsAssignableFrom(service); + Assert.NotNull(service); + } + + [Fact] + public void AddProfiles_is_idempotent_and_returns_same_collection() + { + var services = new ServiceCollection(); + + var returned = services.AddProfiles(); + + Assert.Same(services, returned); + } +} diff --git a/magos-modificus/tests/Magos.Modificus.Profiles.Tests/ProfileServiceFixture.cs b/magos-modificus/tests/Magos.Modificus.Profiles.Tests/ProfileServiceFixture.cs new file mode 100644 index 00000000..c10c2ad9 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Profiles.Tests/ProfileServiceFixture.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Magos.Modificus.Config; + +namespace Magos.Modificus.Profiles.Tests; + +/// +/// Per-test filesystem + DI fixture: a fresh temp ProfilesBaseFolder +/// and an resolved through the real +/// AddProfiles() registration pointing at it. Disposes the temp tree +/// (and the service provider) on teardown so tests are isolated regardless of +/// outcome. +/// +/// +/// Resolving via DI — rather than constructing the implementation directly — +/// keeps the tests black-box against and proves +/// the real registration path on every test. +/// +internal sealed class ProfileServiceFixture : IDisposable +{ + private readonly ServiceProvider _provider; + + public string BaseFolder { get; } = Path.Combine(Path.GetTempPath(), "magos-profiles-" + Guid.NewGuid()); + + public IProfileService Service { get; } + + public ProfileServiceFixture() + { + var config = MagosConfig.CreateDefault(); + config.ProfilesBaseFolder = BaseFolder; + + var services = new ServiceCollection(); + services.AddSingleton(config); + services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning)); // quiet by default + services.AddProfiles(); + _provider = services.BuildServiceProvider(); + + Service = _provider.GetRequiredService(); + } + + public string ProfileDir(Guid id) => Path.Combine(BaseFolder, id.ToString()); + public string ProfileJson(Guid id) => Path.Combine(ProfileDir(id), "profile.json"); + public string ModRoot(Guid id) => Path.Combine(ProfileDir(id), "mods"); + public string ModsLst(Guid id) => Path.Combine(ModRoot(id), "mods.lst"); + + public void Dispose() + { + _provider.Dispose(); + if (Directory.Exists(BaseFolder)) + { + Directory.Delete(BaseFolder, recursive: true); + } + } +}