From e225bd62f19be667b0bf3a1a010ec8f3ce4a4dad Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 29 Jul 2026 05:24:18 +0200 Subject: [PATCH] test(cli): cover install and restore before generalizing them Coverage gate for epic E8's three refactors. Measured the eight files they touch; two findings changed the plan. RestoreRegularFilesUseCase had no dedicated test file anywhere. Its 42.7% statement coverage was incidental, leaking in from higher-level restore and e2e tests, and the unexecuted part was the bodies of collectDrift and applyRestorations, which are exactly the skeleton the restore story extracts. Now 100% statements, 100% branches, via 10 tests covering both drift kinds, all decision modes including the InputRequiredError path, the restored/kept partition, and fileFilter honoured vs null. UpdateAiToolsUseCase and UpdateIdeToolsUseCase were already at 100% branches and statements, so the update story needs no new coverage. Confirmed and left alone rather than padded. The four install content use-cases were all missing the same two branches: the entryFile gate and buildInstallPath returning null. Covered in each, since the generalization collapses them into one implementation and any per-file quirk has to be pinned first. Two asymmetries are now pinned rather than smoothed over. copilot's handlers return null for .gitkeep where claude's never do. InstallAgentsUseCase takes an extra ALL_TOOL_SUFFIXES parameter the other three lack. Pinned as suspicious, not fixed: a drifted file with no entry in the dist map is silently dropped, neither restored nor kept nor reported. One branch stays uncovered in all four install files: \`.split("/").at(-1) ?? relativeFileName\`. split never returns an empty array, so the fallback is unreachable. Documented rather than faked with a test that cannot fail. No production code changed. 2136/2136 pass (2118 plus 18), tsc clean. The new restore tests were verified to bite: inverting the skip check in applyRestorations fails 6 of 10, and inverting the force/interactive gate in resolve-restore-decision fails 3 of 10. --no-verify: biome OOMs here; each changed file was checked individually and reports no fixes. Co-Authored-By: Claude Opus 5 --- .../findings.md | 91 ++++++ .../install-agents-use-case.unit.test.ts | 63 +++- .../install-commands-use-case.unit.test.ts | 19 +- .../install-rules-use-case.unit.test.ts | 63 +++- .../install-skills-use-case.unit.test.ts | 21 +- ...estore-regular-files-use-case.unit.test.ts | 305 ++++++++++++++++++ 6 files changed, 558 insertions(+), 4 deletions(-) create mode 100644 cli/aidd_docs/tasks/2026_07/2026_07_29_e8-01-install-restore-coverage/findings.md create mode 100644 cli/tests/application/use-cases/shared/restore-regular-files-use-case.unit.test.ts diff --git a/cli/aidd_docs/tasks/2026_07/2026_07_29_e8-01-install-restore-coverage/findings.md b/cli/aidd_docs/tasks/2026_07/2026_07_29_e8-01-install-restore-coverage/findings.md new file mode 100644 index 000000000..0e9657660 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_07/2026_07_29_e8-01-install-restore-coverage/findings.md @@ -0,0 +1,91 @@ +--- +objective: "Build the coverage safety net that US-E8-02, US-E8-03 and US-E8-04 need before their refactors touch install and restore code." +status: done +--- + +# SPIKE-E8-01: install/restore coverage + +Baseline confirmed before any change: 2118 passing tests (`pnpm test`, 196 files). After this spike: 2136 passing tests (197 files, 18 new), zero failures. + +## Coverage, before / after + +| File | Branch before | Branch after | Stmt before | Stmt after | Needed by | +| --- | ---: | ---: | ---: | ---: | --- | +| `shared/restore-regular-files-use-case.ts` | 72.7% | **100%** | 42.7% | **100%** | US-E8-04 (blocking) | +| `install/install-agents-use-case.ts` | 88.2% | 94.7% | 95.7% | 100% | US-E8-02 | +| `install/install-commands-use-case.ts` | 88.9% | 94.4% | 100% | 100% | US-E8-02 | +| `install/install-rules-use-case.ts` | 87.5% | 94.4% | 95.0% | 100% | US-E8-02 | +| `install/install-skills-use-case.ts` | 88.9% | 94.4% | 100% | 100% | US-E8-02 | +| `shared/restore-merge-files-use-case.ts` | 96.6% | 96.6% | 100% | 100% | US-E8-04 (already fine, untouched) | +| `global/update-ai-tools-use-case.ts` | 100% | 100% | 100% | 100% | US-E8-03, nothing to do | +| `global/update-ide-tools-use-case.ts` | 100% | 100% | 100% | 100% | US-E8-03, nothing to do | + +Measured with `vitest --coverage` over `unit` + `integration`, `--coverage.reporter=json-summary`, scoped per file via `--coverage.include`. Numbers match the task brief's baseline exactly, confirmed before writing any test. + +US-E8-03 needs no new coverage. Both `update-ai-tools-use-case.ts` and `update-ide-tools-use-case.ts` were already at 100%/100% before this spike and remain there. No test file was touched for either. + +## Priority 1: RestoreRegularFilesUseCase + +No dedicated test file existed anywhere in the repo for this class. Its 42.7% statement coverage was incidental, leaking in from `restore-use-case.unit.test.ts`, which only exercises it indirectly through the full `RestoreUseCase` (and the fixture files that test uses never hit several paths directly). + +New file: `tests/application/use-cases/shared/restore-regular-files-use-case.unit.test.ts`, 10 tests, instantiating `RestoreRegularFilesUseCase` directly against `buildUnitDeps`'s in-memory `fs` and `DeterministicHasher`, with `KeepPrompter` / `OverwritePrompter` / `ScriptedPrompter` from the existing prompter fakes. + +Covered: +- drift kind "deleted" (file absent from disk) restoring from the dist map, without prompting and without `--force`. +- drift kind "modified" (disk content differs from the manifest hash), across all three decision modes: `force=true` (no prompt), `interactive=true` with the prompter choosing keep, `interactive=true` with the prompter choosing overwrite, and neither force nor interactive (throws `InputRequiredError`, and the file is left untouched by the throw). +- the restored/kept partition within a single call, using a scripted prompter with two files that get different answers in the same `execute()`. +- `fileFilter` honoured (excludes a file from drift collection entirely, so it is never even considered restored or kept) versus `fileFilter: null` (all manifest files considered). +- `execute()` returning `null` when nothing has drifted. +- two edge cases not named in the task brief but present in the uncovered line ranges: a manifest-tracked file that drifts (deleted or modified) but has no corresponding entry in the dist map is silently dropped, not restored, not kept, no error. See "Suspicious behaviour" below. + +Result: 100% branches, 100% statements (up from 72.7% / 42.7%), exceeding the story's ask. + +### Mutation evidence + +Two mutations applied to production code, one test run each, then reverted (confirmed via `git diff src/` returning empty afterward): + +1. `restore-regular-files-use-case.ts`, `applyRestorations`: inverted `if (skip)` to `if (!skip)`, which swaps which files land in `restored` versus `kept`. Result: 6 of 10 tests failed (all four decision-mode tests, the fileFilter test, and the multi-file partition test). +2. `resolve-restore-decision.ts`: inverted `if (!force && !interactive)` to `if (force && !interactive)`, which flips when the non-interactive/non-force gate throws `InputRequiredError`. Result: 3 of 10 tests failed (the throw test itself, the force=true test, and the fileFilter test, which also runs in force mode). + +Both mutations were caught, confirmed failing, then reverted and confirmed green again (10/10 pass, `git diff src/` empty). + +## Priority 2: the four install content use-cases + +All four (`install-agents`, `install-rules`, `install-commands`, `install-skills`) share the same `processFile` skeleton: `startsWith` directory check, `acceptsFileName`, an `entryFile` gate, `buildInstallPath`, then a `.gitkeep` special case. Confirmed identical structure by diffing the four files directly, not by assumption. + +Pulled the exact missing branch line numbers from `coverage-final.json` per file (not just the summary) before writing anything: + +| File | Missing branch lines | What they are | +| --- | --- | --- | +| `install-agents-use-case.ts` | 43, 48 | `entryFile !== null` gate never entered; `buildInstallPath` returning `null` never hit | +| `install-rules-use-case.ts` | 40, 45 | same two, rules had no `entryFile` test at all | +| `install-commands-use-case.ts` | 41, 45 | `entryFile` gate was already covered by an existing test; `buildInstallPath === null` was not | +| `install-skills-use-case.ts` | 41, 45 | `entryFile` gate already covered (both directions); `buildInstallPath === null` was not | + +What was added: +- `install-agents-use-case.unit.test.ts` and `install-rules-use-case.unit.test.ts`: a new `entryFile`-set `ContentSection` plus two tests each (accepts the matching basename, filters out a mismatching one), matching the pattern the `install-skills` test file already used for its own `entryFile` coverage. `install-commands-use-case.unit.test.ts` already had an equivalent test (`"respects entryFile filter when section has an entryFile"`); left it untouched. +- All four files: one new test using the `copilot` tool config (imported alongside `claude`) installing a `.gitkeep` file. Claude's `buildInstallPath` never returns `null`, so claude's existing `.gitkeep` test always takes the "empty-content `InstallationFile`" branch. Copilot's `agentsHandler` / `rulesHandler` / `commandsHandler` / `skillsHandler.buildFilePath` all explicitly `return null` when the basename is `.gitkeep` (checked in `src/domain/tools/ai/copilot.ts`), which is a real, reachable path to the `outputPath === null` branch and a genuine behavioural difference between tools: with `claude`, a tracked `.gitkeep` produces an empty `InstallationFile`; with `copilot`, it is filtered out of the result entirely. Pinned as-is; not a bug, just an asymmetry the US-E8-02 generalisation needs to preserve. +- Confirmed the documented asymmetry: `InstallAgentsUseCase.execute` calls `cap.acceptsFileName(relativeFileName, ALL_TOOL_SUFFIXES)` with the extra `ALL_TOOL_SUFFIXES` argument the other three don't pass. This was already exercised by the existing "filters out agent files for other tools" test (unchanged); the new `entryFile` and `.gitkeep` tests for agents go through the same call site, so the asymmetric signature stays exercised under the new cases too. + +Result per file: statements 100% (up from 95.0-100%), branches 94.4-94.7% (up from 87.5-88.9%). + +### Remaining uncovered branch (dead code, not pursued) + +Every one of the four files has this line inside `processFile`: + +```ts +const basename = relativeFileName.split("/").at(-1) ?? relativeFileName; +``` + +The `?? relativeFileName` fallback is unreachable: `String.prototype.split` always returns an array with at least one element, so `.at(-1)` on its result is only ever `undefined` if the array were empty, which cannot happen. No test input can trigger this branch. It is the one remaining branch miss in all four coverage-after numbers above (1 out of 18-19 branches per file). Reported here rather than forced with a fake test, per the characterization-only rule: pinning a branch that cannot execute would mean asserting nothing real. + +## Suspicious behaviour found, deliberately pinned rather than fixed + +In `RestoreRegularFilesUseCase.collectDrift`, when a manifest-tracked file has drifted (deleted from disk, or modified on disk) but has no corresponding entry in `distMap`, the drift is silently dropped: nothing is pushed to the `drift` array, so the file appears in neither `restored` nor `kept`. If it was the only file being restored, `execute()` returns `null`, i.e. "nothing to restore", indistinguishable from the file never having drifted at all. No error, no log, no indication that a tracked file is out of sync with no way to reconcile it. Two tests pin this exact behaviour (`"silently drops a deleted file..."` / `"silently drops a modified file..."`). Whether this is intended (dist map legitimately doesn't have every manifest entry in some scenario) or a gap worth surfacing to the user is a product question, not something this spike should decide by asserting different behaviour. + +## Verification + +- `npx tsc --noEmit`: no errors. +- `pnpm test`: 197 files, 2136 tests, 0 failures (2118 baseline + 18 new). +- Biome (`./node_modules/.bin/biome check --write `, one file at a time): all 5 changed/new files report "No fixes applied" (one auto-fix applied to the new restore-regular-files test file's import wrapping, confirmed clean on the following run). +- Mutation testing on the blocking file: see "Mutation evidence" above. diff --git a/cli/tests/application/use-cases/install/install-agents-use-case.unit.test.ts b/cli/tests/application/use-cases/install/install-agents-use-case.unit.test.ts index 87b7b8ce4..2d5068714 100644 --- a/cli/tests/application/use-cases/install/install-agents-use-case.unit.test.ts +++ b/cli/tests/application/use-cases/install/install-agents-use-case.unit.test.ts @@ -1,10 +1,12 @@ -// Register the claude tool so its capabilities are accessible +// Register the claude and copilot tools so their capabilities are accessible import "../../../../src/domain/tools/ai/claude.js"; +import "../../../../src/domain/tools/ai/copilot.js"; import { describe, expect, it } from "vitest"; import { InstallAgentsUseCase } from "../../../../src/application/use-cases/install/install-agents-use-case.js"; import type { ContentSection } from "../../../../src/domain/models/framework.js"; import { GITKEEP_FILE } from "../../../../src/domain/models/framework.js"; import { claude } from "../../../../src/domain/tools/ai/claude.js"; +import { copilot } from "../../../../src/domain/tools/ai/copilot.js"; import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; const DOCS_DIR = "aidd_docs"; @@ -15,6 +17,12 @@ const agentsSection: ContentSection = { entryFile: null, }; +const agentsSectionWithEntry: ContentSection = { + name: "agents", + directory: "agents", + entryFile: "AGENT.md", +}; + function buildUseCase() { const hasher = new DeterministicHasher(); const useCase = new InstallAgentsUseCase(hasher); @@ -146,4 +154,57 @@ describe("InstallAgentsUseCase", () => { expect(paths).toContain(".claude/agents/agent-b.md"); }); }); + + describe("execute — entryFile section", () => { + it("accepts only the entryFile-named file and installs it", () => { + const { useCase } = buildUseCase(); + const contentFiles = new Map([ + ["agents/my-agent/AGENT.md", "---\nname: my-agent\ndescription: My agent\n---\n# Agent\n"], + ["agents/my-agent/other.claude.md", "---\nname: other\ndescription: Other\n---\n# Other\n"], + ]); + + const files = useCase.execute({ + toolConfig: claude, + section: agentsSectionWithEntry, + contentFiles, + docsDir: DOCS_DIR, + }); + + expect(files).toHaveLength(1); + expect(files[0].frameworkPath).toBe("agents/my-agent/AGENT.md"); + }); + + it("filters out files whose basename does not match entryFile", () => { + const { useCase } = buildUseCase(); + const contentFiles = new Map([ + ["agents/reviewer/helper.claude.md", "# helper — not AGENT.md basename"], + ]); + + const files = useCase.execute({ + toolConfig: claude, + section: agentsSectionWithEntry, + contentFiles, + docsDir: DOCS_DIR, + }); + + expect(files).toHaveLength(0); + }); + }); + + describe("execute — tool with a null install path for .gitkeep", () => { + it("filters out the .gitkeep file entirely instead of producing an empty InstallationFile", () => { + const { useCase } = buildUseCase(); + const gitkeepPath = `agents/${GITKEEP_FILE}`; + const contentFiles = new Map([[gitkeepPath, ""]]); + + const files = useCase.execute({ + toolConfig: copilot, + section: agentsSection, + contentFiles, + docsDir: DOCS_DIR, + }); + + expect(files).toHaveLength(0); + }); + }); }); diff --git a/cli/tests/application/use-cases/install/install-commands-use-case.unit.test.ts b/cli/tests/application/use-cases/install/install-commands-use-case.unit.test.ts index b98a7d4da..3d5f3420d 100644 --- a/cli/tests/application/use-cases/install/install-commands-use-case.unit.test.ts +++ b/cli/tests/application/use-cases/install/install-commands-use-case.unit.test.ts @@ -1,10 +1,12 @@ -// Register the claude tool so its capabilities are accessible +// Register the claude and copilot tools so their capabilities are accessible import "../../../../src/domain/tools/ai/claude.js"; +import "../../../../src/domain/tools/ai/copilot.js"; import { describe, expect, it } from "vitest"; import { InstallCommandsUseCase } from "../../../../src/application/use-cases/install/install-commands-use-case.js"; import type { ContentSection } from "../../../../src/domain/models/framework.js"; import { GITKEEP_FILE } from "../../../../src/domain/models/framework.js"; import { claude } from "../../../../src/domain/tools/ai/claude.js"; +import { copilot } from "../../../../src/domain/tools/ai/copilot.js"; import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; const DOCS_DIR = "aidd_docs"; @@ -151,5 +153,20 @@ describe("InstallCommandsUseCase", () => { const paths = files.map((f) => f.frameworkPath); expect(paths).not.toContain("commands/other.claude.md"); }); + + it("filters out the .gitkeep file entirely when the tool's install path is null for it", () => { + const { useCase } = buildUseCase(); + const gitkeepPath = `commands/04_code/${GITKEEP_FILE}`; + const contentFiles = new Map([[gitkeepPath, ""]]); + + const files = useCase.execute({ + toolConfig: copilot, + section: commandsSection, + contentFiles, + docsDir: DOCS_DIR, + }); + + expect(files).toHaveLength(0); + }); }); }); diff --git a/cli/tests/application/use-cases/install/install-rules-use-case.unit.test.ts b/cli/tests/application/use-cases/install/install-rules-use-case.unit.test.ts index 59a6c07d7..d0dc7e504 100644 --- a/cli/tests/application/use-cases/install/install-rules-use-case.unit.test.ts +++ b/cli/tests/application/use-cases/install/install-rules-use-case.unit.test.ts @@ -1,10 +1,12 @@ -// Register the claude tool so its capabilities are accessible +// Register the claude and copilot tools so their capabilities are accessible import "../../../../src/domain/tools/ai/claude.js"; +import "../../../../src/domain/tools/ai/copilot.js"; import { describe, expect, it } from "vitest"; import { InstallRulesUseCase } from "../../../../src/application/use-cases/install/install-rules-use-case.js"; import type { ContentSection } from "../../../../src/domain/models/framework.js"; import { GITKEEP_FILE } from "../../../../src/domain/models/framework.js"; import { claude } from "../../../../src/domain/tools/ai/claude.js"; +import { copilot } from "../../../../src/domain/tools/ai/copilot.js"; import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; const DOCS_DIR = "aidd_docs"; @@ -15,6 +17,12 @@ const rulesSection: ContentSection = { entryFile: null, }; +const rulesSectionWithEntry: ContentSection = { + name: "rules", + directory: "rules", + entryFile: "RULE.md", +}; + function buildUseCase() { const hasher = new DeterministicHasher(); const useCase = new InstallRulesUseCase(hasher); @@ -162,4 +170,57 @@ describe("InstallRulesUseCase", () => { expect(paths).toContain(".claude/rules/02-patterns/rule-b.md"); }); }); + + describe("execute — entryFile section", () => { + it("accepts only the entryFile-named file and installs it", () => { + const { useCase } = buildUseCase(); + const contentFiles = new Map([ + ["rules/my-rule/RULE.md", "---\npaths:\n - src/**\n---\n# Rule\n"], + ["rules/my-rule/other.claude.md", "---\npaths:\n - src/**\n---\n# Other\n"], + ]); + + const files = useCase.execute({ + toolConfig: claude, + section: rulesSectionWithEntry, + contentFiles, + docsDir: DOCS_DIR, + }); + + expect(files).toHaveLength(1); + expect(files[0].frameworkPath).toBe("rules/my-rule/RULE.md"); + }); + + it("filters out files whose basename does not match entryFile", () => { + const { useCase } = buildUseCase(); + const contentFiles = new Map([ + ["rules/standards/helper.claude.md", "# helper — not RULE.md basename"], + ]); + + const files = useCase.execute({ + toolConfig: claude, + section: rulesSectionWithEntry, + contentFiles, + docsDir: DOCS_DIR, + }); + + expect(files).toHaveLength(0); + }); + }); + + describe("execute — tool with a null install path for .gitkeep", () => { + it("filters out the .gitkeep file entirely instead of producing an empty InstallationFile", () => { + const { useCase } = buildUseCase(); + const gitkeepPath = `rules/${GITKEEP_FILE}`; + const contentFiles = new Map([[gitkeepPath, ""]]); + + const files = useCase.execute({ + toolConfig: copilot, + section: rulesSection, + contentFiles, + docsDir: DOCS_DIR, + }); + + expect(files).toHaveLength(0); + }); + }); }); diff --git a/cli/tests/application/use-cases/install/install-skills-use-case.unit.test.ts b/cli/tests/application/use-cases/install/install-skills-use-case.unit.test.ts index 75b9d015c..d9b5f44be 100644 --- a/cli/tests/application/use-cases/install/install-skills-use-case.unit.test.ts +++ b/cli/tests/application/use-cases/install/install-skills-use-case.unit.test.ts @@ -1,10 +1,12 @@ -// Register the claude tool so its capabilities are accessible +// Register the claude and copilot tools so their capabilities are accessible import "../../../../src/domain/tools/ai/claude.js"; +import "../../../../src/domain/tools/ai/copilot.js"; import { describe, expect, it } from "vitest"; import { InstallSkillsUseCase } from "../../../../src/application/use-cases/install/install-skills-use-case.js"; import type { ContentSection } from "../../../../src/domain/models/framework.js"; import { GITKEEP_FILE } from "../../../../src/domain/models/framework.js"; import { claude } from "../../../../src/domain/tools/ai/claude.js"; +import { copilot } from "../../../../src/domain/tools/ai/copilot.js"; import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; const DOCS_DIR = "aidd_docs"; @@ -172,4 +174,21 @@ describe("InstallSkillsUseCase", () => { expect(files).toHaveLength(0); }); }); + + describe("execute — tool with a null install path for .gitkeep", () => { + it("filters out the .gitkeep file entirely instead of producing an empty InstallationFile", () => { + const { useCase } = buildUseCase(); + const gitkeepPath = `skills/${GITKEEP_FILE}`; + const contentFiles = new Map([[gitkeepPath, ""]]); + + const files = useCase.execute({ + toolConfig: copilot, + section: skillsSectionFlat, + contentFiles, + docsDir: DOCS_DIR, + }); + + expect(files).toHaveLength(0); + }); + }); }); diff --git a/cli/tests/application/use-cases/shared/restore-regular-files-use-case.unit.test.ts b/cli/tests/application/use-cases/shared/restore-regular-files-use-case.unit.test.ts new file mode 100644 index 000000000..93f19d59c --- /dev/null +++ b/cli/tests/application/use-cases/shared/restore-regular-files-use-case.unit.test.ts @@ -0,0 +1,305 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { InputRequiredError } from "../../../../src/application/errors.js"; +import { RestoreRegularFilesUseCase } from "../../../../src/application/use-cases/shared/restore-regular-files-use-case.js"; +import { InstallationFile } from "../../../../src/domain/models/file.js"; +import { buildUnitDeps } from "../../../helpers/ports/build-unit-deps.js"; +import { + KeepPrompter, + OverwritePrompter, + ScriptedPrompter, +} from "../../../helpers/ports/scripted-prompter.js"; + +const PROJECT_ROOT = "/test-project"; + +async function buildDeps() { + return buildUnitDeps(PROJECT_ROOT); +} + +describe("RestoreRegularFilesUseCase", () => { + it("returns null when no manifest file has drifted", async () => { + const deps = await buildDeps(); + await deps.fs.writeFile(join(PROJECT_ROOT, "a.md"), "current content"); + const useCase = new RestoreRegularFilesUseCase(deps.fs, new OverwritePrompter()); + + const result = await useCase.execute({ + manifestFiles: [{ relativePath: "a.md", hash: deps.hasher.hash("current content") }], + distMap: new Map(), + projectRoot: PROJECT_ROOT, + force: false, + interactive: false, + fileFilter: null, + }); + + expect(result).toBeNull(); + }); + + it("restores a file deleted from disk, without prompting, even without force", async () => { + const deps = await buildDeps(); + const useCase = new RestoreRegularFilesUseCase(deps.fs, new OverwritePrompter()); + const distMap = new Map([ + [ + "a.md", + new InstallationFile({ + relativePath: "a.md", + content: "framework content", + hash: deps.hasher.hash("framework content"), + }), + ], + ]); + + const result = await useCase.execute({ + manifestFiles: [{ relativePath: "a.md", hash: deps.hasher.hash("original content") }], + distMap, + projectRoot: PROJECT_ROOT, + force: false, + interactive: false, + fileFilter: null, + }); + + expect(result).not.toBeNull(); + expect(result?.restored).toEqual(["a.md"]); + expect(result?.kept).toEqual([]); + expect(deps.fs.getFile(join(PROJECT_ROOT, "a.md"))).toBe("framework content"); + const updated = result?.updatedFiles.find((f) => f.relativePath === "a.md"); + expect(updated?.hash).toEqual(deps.hasher.hash("framework content")); + }); + + it("throws InputRequiredError for a modified file when force=false and interactive=false, without writing", async () => { + const deps = await buildDeps(); + await deps.fs.writeFile(join(PROJECT_ROOT, "a.md"), "disk modified content"); + const useCase = new RestoreRegularFilesUseCase(deps.fs, new OverwritePrompter()); + const distMap = new Map([ + [ + "a.md", + new InstallationFile({ + relativePath: "a.md", + content: "framework content", + hash: deps.hasher.hash("framework content"), + }), + ], + ]); + + await expect( + useCase.execute({ + manifestFiles: [{ relativePath: "a.md", hash: deps.hasher.hash("original content") }], + distMap, + projectRoot: PROJECT_ROOT, + force: false, + interactive: false, + fileFilter: null, + }) + ).rejects.toThrow(InputRequiredError); + + expect(deps.fs.getFile(join(PROJECT_ROOT, "a.md"))).toBe("disk modified content"); + }); + + it("overwrites a modified file when force=true without prompting", async () => { + const deps = await buildDeps(); + await deps.fs.writeFile(join(PROJECT_ROOT, "a.md"), "disk modified content"); + const useCase = new RestoreRegularFilesUseCase(deps.fs, new OverwritePrompter()); + const distMap = new Map([ + [ + "a.md", + new InstallationFile({ + relativePath: "a.md", + content: "framework content", + hash: deps.hasher.hash("framework content"), + }), + ], + ]); + + const result = await useCase.execute({ + manifestFiles: [{ relativePath: "a.md", hash: deps.hasher.hash("original content") }], + distMap, + projectRoot: PROJECT_ROOT, + force: true, + interactive: false, + fileFilter: null, + }); + + expect(result?.restored).toEqual(["a.md"]); + expect(deps.fs.getFile(join(PROJECT_ROOT, "a.md"))).toBe("framework content"); + }); + + it("keeps a modified file when interactive=true and the prompter chooses keep", async () => { + const deps = await buildDeps(); + await deps.fs.writeFile(join(PROJECT_ROOT, "a.md"), "disk modified content"); + const useCase = new RestoreRegularFilesUseCase(deps.fs, new KeepPrompter()); + const originalHash = deps.hasher.hash("original content"); + const distMap = new Map([ + [ + "a.md", + new InstallationFile({ + relativePath: "a.md", + content: "framework content", + hash: deps.hasher.hash("framework content"), + }), + ], + ]); + + const result = await useCase.execute({ + manifestFiles: [{ relativePath: "a.md", hash: originalHash }], + distMap, + projectRoot: PROJECT_ROOT, + force: false, + interactive: true, + fileFilter: null, + }); + + expect(result?.kept).toEqual(["a.md"]); + expect(result?.restored).toEqual([]); + expect(deps.fs.getFile(join(PROJECT_ROOT, "a.md"))).toBe("disk modified content"); + const updated = result?.updatedFiles.find((f) => f.relativePath === "a.md"); + expect(updated?.hash).toEqual(originalHash); + }); + + it("overwrites a modified file when interactive=true and the prompter chooses overwrite", async () => { + const deps = await buildDeps(); + await deps.fs.writeFile(join(PROJECT_ROOT, "a.md"), "disk modified content"); + const useCase = new RestoreRegularFilesUseCase(deps.fs, new OverwritePrompter()); + const distMap = new Map([ + [ + "a.md", + new InstallationFile({ + relativePath: "a.md", + content: "framework content", + hash: deps.hasher.hash("framework content"), + }), + ], + ]); + + const result = await useCase.execute({ + manifestFiles: [{ relativePath: "a.md", hash: deps.hasher.hash("original content") }], + distMap, + projectRoot: PROJECT_ROOT, + force: false, + interactive: true, + fileFilter: null, + }); + + expect(result?.restored).toEqual(["a.md"]); + expect(deps.fs.getFile(join(PROJECT_ROOT, "a.md"))).toBe("framework content"); + }); + + it("excludes files that fail the fileFilter predicate from drift collection entirely", async () => { + const deps = await buildDeps(); + await deps.fs.writeFile(join(PROJECT_ROOT, "a.md"), "disk modified content"); + const useCase = new RestoreRegularFilesUseCase(deps.fs, new OverwritePrompter()); + const distMap = new Map([ + [ + "a.md", + new InstallationFile({ + relativePath: "a.md", + content: "framework a", + hash: deps.hasher.hash("framework a"), + }), + ], + [ + "b.md", + new InstallationFile({ + relativePath: "b.md", + content: "framework b", + hash: deps.hasher.hash("framework b"), + }), + ], + ]); + + const result = await useCase.execute({ + manifestFiles: [ + { relativePath: "a.md", hash: deps.hasher.hash("original a") }, + { relativePath: "b.md", hash: deps.hasher.hash("original b") }, + ], + distMap, + projectRoot: PROJECT_ROOT, + force: true, + interactive: false, + fileFilter: (relativePath) => relativePath === "a.md", + }); + + expect(result?.restored).toEqual(["a.md"]); + expect(result?.kept).toEqual([]); + expect(deps.fs.has(join(PROJECT_ROOT, "b.md"))).toBe(false); + }); + + it("partitions multiple drifted files into restored and kept within a single call", async () => { + const deps = await buildDeps(); + await deps.fs.writeFile(join(PROJECT_ROOT, "a.md"), "disk modified a"); + await deps.fs.writeFile(join(PROJECT_ROOT, "b.md"), "disk modified b"); + const prompter = new ScriptedPrompter([ + ScriptedPrompter.answer.conflict("overwrite"), + ScriptedPrompter.answer.conflict("keep"), + ]); + const useCase = new RestoreRegularFilesUseCase(deps.fs, prompter); + const distMap = new Map([ + [ + "a.md", + new InstallationFile({ + relativePath: "a.md", + content: "framework a", + hash: deps.hasher.hash("framework a"), + }), + ], + [ + "b.md", + new InstallationFile({ + relativePath: "b.md", + content: "framework b", + hash: deps.hasher.hash("framework b"), + }), + ], + ]); + + const result = await useCase.execute({ + manifestFiles: [ + { relativePath: "a.md", hash: deps.hasher.hash("original a") }, + { relativePath: "b.md", hash: deps.hasher.hash("original b") }, + ], + distMap, + projectRoot: PROJECT_ROOT, + force: false, + interactive: true, + fileFilter: null, + }); + + expect(result?.restored).toEqual(["a.md"]); + expect(result?.kept).toEqual(["b.md"]); + expect(deps.fs.getFile(join(PROJECT_ROOT, "a.md"))).toBe("framework a"); + expect(deps.fs.getFile(join(PROJECT_ROOT, "b.md"))).toBe("disk modified b"); + }); + + it("silently drops a deleted file that has no corresponding entry in the dist map", async () => { + const deps = await buildDeps(); + const useCase = new RestoreRegularFilesUseCase(deps.fs, new OverwritePrompter()); + + const result = await useCase.execute({ + manifestFiles: [{ relativePath: "a.md", hash: deps.hasher.hash("original content") }], + distMap: new Map(), + projectRoot: PROJECT_ROOT, + force: true, + interactive: false, + fileFilter: null, + }); + + expect(result).toBeNull(); + expect(deps.fs.has(join(PROJECT_ROOT, "a.md"))).toBe(false); + }); + + it("silently drops a modified file that has no corresponding entry in the dist map", async () => { + const deps = await buildDeps(); + await deps.fs.writeFile(join(PROJECT_ROOT, "a.md"), "disk modified content"); + const useCase = new RestoreRegularFilesUseCase(deps.fs, new OverwritePrompter()); + + const result = await useCase.execute({ + manifestFiles: [{ relativePath: "a.md", hash: deps.hasher.hash("original content") }], + distMap: new Map(), + projectRoot: PROJECT_ROOT, + force: true, + interactive: false, + fileFilter: null, + }); + + expect(result).toBeNull(); + expect(deps.fs.getFile(join(PROJECT_ROOT, "a.md"))).toBe("disk modified content"); + }); +});