using System.Text.Json; using ClaudeDo.Data; using ClaudeDo.Worker.Lifecycle; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; namespace ClaudeDo.Worker.Tests.Lifecycle; public sealed class PromptFileRecoveryTests { private sealed class CapturingLogger : ILogger { public List Messages { get; } = new(); public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => Messages.Add(formatter(state, exception)); private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } } [Fact] public async Task StartAsync_ReconcilesStaleDefaultAndQuarantinesOrphan_WithoutTouchingRealEdit() { var root = Path.Combine(Path.GetTempPath(), $"claudedo_prompts_{Guid.NewGuid():N}"); try { // A file that only ever matched a now-superseded default should get reconciled away. Directory.CreateDirectory(root); const string oldDefaultText = "This used to be the bundled retry default."; File.WriteAllText(PromptFiles.PathFor(PromptKind.Retry, root), oldDefaultText); var hashes = new Dictionary { [PromptKind.Retry.ToString()] = PromptFiles.HashOf(PromptFiles.Normalize(oldDefaultText)) }; File.WriteAllText(Path.Combine(root, ".defaults.json"), JsonSerializer.Serialize(hashes)); // A genuinely edited file should survive untouched. PromptFiles.Save(PromptKind.System, "My real customization.", root); // A leftover file from a retired naming scheme should be quarantined, not deleted. var orphanPath = Path.Combine(root, "agent.md"); File.WriteAllText(orphanPath, "leftover"); var sut = new PromptFileRecovery(NullLogger.Instance, root); await sut.StartAsync(CancellationToken.None); Assert.False(File.Exists(PromptFiles.PathFor(PromptKind.Retry, root)), "Stale unedited default must be reconciled away"); Assert.True(File.Exists(PromptFiles.PathFor(PromptKind.System, root)), "Real edit must survive"); Assert.False(File.Exists(orphanPath), "Orphan must be moved out of the prompts root"); Assert.True(File.Exists(Path.Combine(root, "_orphans", "agent.md")), "Orphan must be quarantined, not deleted"); await sut.StopAsync(CancellationToken.None); // must not throw } finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } } [Fact] public async Task StartAsync_QuarantinesOrphanedMergeHelperExecute_WarnNamesFileAndReplacements() { var root = Path.Combine(Path.GetTempPath(), $"claudedo_prompts_{Guid.NewGuid():N}"); try { Directory.CreateDirectory(root); var orphanPath = Path.Combine(root, "merge-helper-execute.md"); File.WriteAllText(orphanPath, "my old customized triage+wait+merge prompt"); var logger = new CapturingLogger(); var sut = new PromptFileRecovery(logger, root); await sut.StartAsync(CancellationToken.None); var dest = Path.Combine(root, "_orphans", "merge-helper-execute.md"); Assert.False(File.Exists(orphanPath)); Assert.True(File.Exists(dest)); Assert.Contains(logger.Messages, m => m.Contains("merge-helper-execute.md") && m.Contains(dest)); Assert.Contains(logger.Messages, m => m.Contains("merge-helper-wait.md") && m.Contains("merge-helper-merge.md")); } finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } } }