diff --git a/ProjectDirector.Test/UnsupportedRepoTests.cs b/ProjectDirector.Test/UnsupportedRepoTests.cs new file mode 100644 index 0000000..64d8db7 --- /dev/null +++ b/ProjectDirector.Test/UnsupportedRepoTests.cs @@ -0,0 +1,248 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.Test; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests the rule that refuses a saved repository this application cannot act on. +/// +/// +/// registers as a derived type for +/// polymorphic JSON, so a saved options file carrying one deserializes without complaint. Nothing +/// then acts on it: every site that pattern-matches a repository handles +/// and throws otherwise. UpdateClonedStatus runs from the +/// constructor's RefreshPage, so that throw landed on the next launch, before the user could +/// open the UI to delete the entry causing it -- unrecoverable without hand-editing the file. +/// +/// RejectUnsupportedRepos and MakeLoadedOptionsSafe exist so the one boundary such an +/// entry can arrive through can be driven without a live ImGui context, the way +/// drives the pull rule. What it guarantees is the invariant those six throw sites rest on: after it +/// runs, every repository left in the options is a . +/// +[TestClass] +public sealed class UnsupportedRepoTests +{ + private static FullyQualifiedGitHubRepoName RepoName(string value) => FullyQualifiedGitHubRepoName.Create(value); + private static FullyQualifiedLocalRepoPath LocalPath(string value) => FullyQualifiedLocalRepoPath.Create(value); + + /// + /// The premise: an Azure DevOps repository really does come back from JSON as a live object, + /// rather than being rejected by the serializer. + /// + /// + /// The discriminator alone is enough, which is the point -- this is what a hand-edited options + /// file, or one left over from a partially built feature, looks like. + /// + [TestMethod] + public void AnAzureDevOpsRepositoryDeserializesIntoALiveObject() + { + GitRepository? fromSavedOptions = JsonSerializer.Deserialize("""{"TypeName":"AzureDevOpsRepository"}"""); + + Assert.IsInstanceOfType(fromSavedOptions, + "If this stops holding, the crash this rule guards against is no longer reachable and the rule can go."); + } + + /// + /// The regression this file exists for. + /// + [TestMethod] + public void AnUnsupportedRepoIsDroppedAndTheSupportedOnesAreKept() + { + Dictionary repos = new() + { + [RepoName("ktsu-dev.ProjectDirector")] = new GitHubRepository(), + [RepoName("contoso.internal")] = new AzureDevOpsRepository(), + }; + Dictionary clonedRepos = []; + + IReadOnlyList rejected = ProjectDirector.RejectUnsupportedRepos(repos, clonedRepos); + + CollectionAssert.AreEqual(new[] { RepoName("contoso.internal") }, rejected.ToArray()); + CollectionAssert.AreEqual( + new[] { RepoName("ktsu-dev.ProjectDirector") }, + repos.Keys.ToArray(), + "The supported repositories must survive."); + + Assert.IsTrue(repos.Values.All(repo => repo is GitHubRepository), + "Every site that pattern-matches a repository rests on this invariant."); + } + + [TestMethod] + public void NothingUnsupportedMeansNothingIsTouched() + { + Dictionary repos = new() + { + [RepoName("ktsu-dev.ProjectDirector")] = new GitHubRepository(), + [RepoName("ktsu-dev.ImGuiApp")] = new GitHubRepository(), + }; + Dictionary clonedRepos = new() + { + [LocalPath("/dev/ProjectDirector")] = RepoName("ktsu-dev.ProjectDirector"), + }; + + IReadOnlyList rejected = ProjectDirector.RejectUnsupportedRepos(repos, clonedRepos); + + Assert.AreEqual(0, rejected.Count); + Assert.AreEqual(2, repos.Count); + Assert.AreEqual(1, clonedRepos.Count); + Assert.AreEqual( + RepoName("ktsu-dev.ProjectDirector"), + ProjectDirector.ClearSelectionIfRejected(RepoName("ktsu-dev.ProjectDirector"), rejected), + "An ordinary selection must not be disturbed."); + } + + [TestMethod] + public void ACloneRecordedAgainstADroppedRepoIsCleared() + { + Dictionary repos = new() + { + [RepoName("contoso.internal")] = new AzureDevOpsRepository(), + [RepoName("ktsu-dev.ProjectDirector")] = new GitHubRepository(), + }; + Dictionary clonedRepos = new() + { + [LocalPath("/dev/internal")] = RepoName("contoso.internal"), + [LocalPath("/dev/ProjectDirector")] = RepoName("ktsu-dev.ProjectDirector"), + }; + + _ = ProjectDirector.RejectUnsupportedRepos(repos, clonedRepos); + + CollectionAssert.AreEqual( + new[] { LocalPath("/dev/ProjectDirector") }, + clonedRepos.Keys.ToArray(), + "A clone must not keep naming a repository that is no longer in the options."); + } + + [TestMethod] + public void EveryRepoBeingUnsupportedLeavesAnEmptyButUsableState() + { + Dictionary repos = new() + { + [RepoName("contoso.internal")] = new AzureDevOpsRepository(), + [RepoName("contoso.other")] = new AzureDevOpsRepository(), + }; + Dictionary clonedRepos = new() + { + [LocalPath("/dev/internal")] = RepoName("contoso.internal"), + }; + + IReadOnlyList rejected = ProjectDirector.RejectUnsupportedRepos(repos, clonedRepos); + + Assert.AreEqual(2, rejected.Count); + Assert.AreEqual(0, repos.Count); + Assert.AreEqual(0, clonedRepos.Count); + } + + /// + /// The whole of what the constructor does after loading, against real options: the crashing + /// entry goes, the selection pointing at it goes with it, the supported repository stays, and + /// the user is told why. + /// + [TestMethod] + public void LoadedOptionsCarryingAnUnsupportedRepoAreMadeSafe() + { + using ProjectDirectorOptions options = new() + { + BaseRepo = RepoName("contoso.internal"), + CompareRepo = RepoName("ktsu-dev.ProjectDirector"), + }; + options.Repos[RepoName("contoso.internal")] = new AzureDevOpsRepository(); + options.Repos[RepoName("ktsu-dev.ProjectDirector")] = new GitHubRepository(); + options.ClonedRepos[LocalPath("/dev/internal")] = RepoName("contoso.internal"); + + List logged = []; + IReadOnlyList rejected = ProjectDirector.MakeLoadedOptionsSafe(options, logged.Add); + + CollectionAssert.AreEqual(new[] { RepoName("contoso.internal") }, rejected.ToArray()); + CollectionAssert.AreEqual(new[] { RepoName("ktsu-dev.ProjectDirector") }, options.Repos.Keys.ToArray()); + Assert.AreEqual(0, options.ClonedRepos.Count); + + Assert.AreEqual(new FullyQualifiedGitHubRepoName(), options.BaseRepo, "The base selection named the rejected repository."); + Assert.AreEqual(RepoName("ktsu-dev.ProjectDirector"), options.CompareRepo, "The compare selection named a surviving one and must be left alone."); + + Assert.AreEqual(1, logged.Count, "Each rejection should be reported once."); + StringAssert.Contains(logged[0], "contoso.internal", StringComparison.Ordinal); + } + + [TestMethod] + public void LoadedOptionsWithNothingUnsupportedAreLeftAlone() + { + using ProjectDirectorOptions options = new() + { + BaseRepo = RepoName("ktsu-dev.ProjectDirector"), + }; + options.Repos[RepoName("ktsu-dev.ProjectDirector")] = new GitHubRepository(); + + List logged = []; + IReadOnlyList rejected = ProjectDirector.MakeLoadedOptionsSafe(options, logged.Add); + + Assert.AreEqual(0, rejected.Count); + Assert.AreEqual(1, options.Repos.Count); + Assert.AreEqual(RepoName("ktsu-dev.ProjectDirector"), options.BaseRepo); + Assert.AreEqual(0, logged.Count, "Nothing to reject means nothing to report."); + } + + /// + /// Fresh options have to be constructible on every platform this repository tests on, which is + /// what a hardcoded C:\dev default prevented. + /// + [TestMethod] + public void FreshOptionsCanBeConstructed() + { + using ProjectDirectorOptions options = new(); + + Assert.IsFalse(string.IsNullOrEmpty(options.DevDirectory), "A fresh install needs a usable dev directory default."); + } + + [TestMethod] + public void EmptyOptionsAreHandled() + { + Dictionary repos = []; + Dictionary clonedRepos = []; + + Assert.AreEqual(0, ProjectDirector.RejectUnsupportedRepos(repos, clonedRepos).Count); + Assert.AreEqual(0, repos.Count); + } + + /// + /// A selection left pointing at a dropped repository would send the panels straight back into + /// Options.Repos[Options.BaseRepo], turning one crash into another. + /// + [TestMethod] + public void ASelectionPointingAtADroppedRepoIsCleared() + { + IReadOnlyList rejected = [RepoName("contoso.internal")]; + + Assert.AreEqual( + new FullyQualifiedGitHubRepoName(), + ProjectDirector.ClearSelectionIfRejected(RepoName("contoso.internal"), rejected), + "The selection must not outlive the repository it names."); + } + + [TestMethod] + public void ASelectionNamingASurvivingRepoIsKept() + { + IReadOnlyList rejected = [RepoName("contoso.internal")]; + + Assert.AreEqual( + RepoName("ktsu-dev.ProjectDirector"), + ProjectDirector.ClearSelectionIfRejected(RepoName("ktsu-dev.ProjectDirector"), rejected)); + } + + [TestMethod] + public void AnEmptySelectionSurvivesAnEmptyRejectionList() + { + IReadOnlyList rejected = []; + + Assert.AreEqual( + new FullyQualifiedGitHubRepoName(), + ProjectDirector.ClearSelectionIfRejected(new FullyQualifiedGitHubRepoName(), rejected), + "A fresh install selects nothing, and that must not be mistaken for a rejection."); + } +} diff --git a/ProjectDirector/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs index 79d2af9..c112877 100644 --- a/ProjectDirector/ProjectDirector.cs +++ b/ProjectDirector/ProjectDirector.cs @@ -62,6 +62,9 @@ private static void Main(string[] _) public ProjectDirector() { Options = ProjectDirectorOptions.LoadOrCreate(); + + _ = MakeLoadedOptionsSafe(Options, QueueLog); + Options.Save(); // ChatClient = new(model: "gpt-4o", new ApiKeyCredential(Options.OpenAIToken)); DividerDiff = new("DiffDivider", DividerResized, ImGuiWidgets.DividerLayout.Columns); @@ -98,6 +101,106 @@ public ProjectDirector() RefreshPage(); } + /// + /// Drops any saved repository this application cannot act on, along with any selection left + /// pointing at one. + /// + /// The freshly loaded repositories, modified in place. + /// The freshly loaded clone records, modified in place. + /// The names of the repositories that were dropped, in the order they were found. + /// + /// registers as a + /// , so a saved options + /// file carrying one deserializes without complaint. Nothing acts on it: every site that + /// pattern-matches a repository handles and throws otherwise, + /// and cannot produce anything else in the first place. + /// UpdateClonedStatus then runs from the constructor's RefreshPage, so the throw + /// landed on the very next launch -- before the user could open the UI and delete the entry + /// that was causing it, which made it unrecoverable without hand-editing the file. + /// Rejecting the entry at the one boundary it can arrive through is what makes that + /// unreachable, rather than guarding six call sites separately. The registration is left in + /// place so an existing file still parses; it is the live object that is refused. + /// The collections are taken rather than the whole so this + /// rule can be driven without constructing one. + /// + internal static IReadOnlyList RejectUnsupportedRepos( + IDictionary repos, + IDictionary clonedRepos) + { + Ensure.NotNull(repos); + Ensure.NotNull(clonedRepos); + + List rejected = [.. repos + .Where(kvp => kvp.Value is not GitHubRepository) + .Select(kvp => kvp.Key)]; + + foreach (FullyQualifiedGitHubRepoName name in rejected) + { + _ = repos.Remove(name); + } + + // A clone recorded against a rejected repository would otherwise keep naming it. + foreach (FullyQualifiedLocalRepoPath path in clonedRepos + .Where(kvp => rejected.Contains(kvp.Value)) + .Select(kvp => kvp.Key) + .ToList()) + { + _ = clonedRepos.Remove(path); + } + + return rejected; + } + + /// + /// Clears a selected repository name that names one of the + /// repositories. + /// + /// The saved selection. + /// The repositories that were dropped. + /// The selection, or an empty name where it named a dropped repository. + /// + /// Once something is selected the panels reach for it through + /// Options.Repos[Options.BaseRepo], so a selection outliving its repository turns one + /// crash into another. An empty name is the state a fresh install starts in, which the + /// surrounding TryGetValue checks already handle. + /// + internal static FullyQualifiedGitHubRepoName ClearSelectionIfRejected( + FullyQualifiedGitHubRepoName selection, + IReadOnlyList rejected) + { + Ensure.NotNull(rejected); + + return rejected.Contains(selection) ? new() : selection; + } + + /// + /// Rejects every saved repository this application cannot act on, clears anything left pointing + /// at one, and reports each rejection. + /// + /// The freshly loaded options, modified in place. + /// Where to report each rejected repository. + /// The names of the repositories that were dropped. + /// + /// The whole of what the constructor does after loading, in one place, so it can be driven + /// without an ImGui context. + /// + internal static IReadOnlyList MakeLoadedOptionsSafe(ProjectDirectorOptions options, Action log) + { + Ensure.NotNull(options); + Ensure.NotNull(log); + + IReadOnlyList rejected = RejectUnsupportedRepos(options.Repos, options.ClonedRepos); + options.BaseRepo = ClearSelectionIfRejected(options.BaseRepo, rejected); + options.CompareRepo = ClearSelectionIfRejected(options.CompareRepo, rejected); + + foreach (FullyQualifiedGitHubRepoName name in rejected) + { + log($"Ignoring saved repository '{name}': only GitHub repositories are supported at this time."); + } + + return rejected; + } + private void QueueLog(string logMessage) { LogQueue.Enqueue(logMessage); diff --git a/ProjectDirector/ProjectDirectorOptions.cs b/ProjectDirector/ProjectDirectorOptions.cs index b624423..7a11160 100644 --- a/ProjectDirector/ProjectDirectorOptions.cs +++ b/ProjectDirector/ProjectDirectorOptions.cs @@ -18,7 +18,22 @@ public sealed record class FullyQualifiedLocalRepoPath : SemanticString { - public AbsoluteDirectoryPath DevDirectory { get; set; } = AbsoluteDirectoryPath.Create(@"C:\dev"); + public AbsoluteDirectoryPath DevDirectory { get; set; } = DefaultDevDirectory(); + + /// + /// The dev directory a fresh install starts with. + /// + /// + /// C:\dev is not an absolute path anywhere but Windows, so + /// rejected it and this type could not be constructed at all + /// off Windows -- not by the application, and not by a test. Windows keeps the path it has + /// always had; everywhere else falls back to ~/dev. Only a fresh install reads this, so a + /// saved options file keeps whatever the user chose. + /// + private static AbsoluteDirectoryPath DefaultDevDirectory() => + AbsoluteDirectoryPath.Create(OperatingSystem.IsWindows() + ? @"C:\dev" + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "dev")); public ImGuiAppWindowState WindowState { get; set; } = new(); public GitHubLogin GitHubLogin { get; set; } = new();