seihou-core-0.4.0.0: test/Seihou/Engine/DiffSpec.hs
module Seihou.Engine.DiffSpec (tests) where
import Data.Map.Strict qualified as Map
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Time (UTCTime, defaultTimeLocale, parseTimeOrError)
import Effectful
import Seihou.Core.Types
import Seihou.Effect.FilesystemPure (PureFS (..), emptyFS, runFilesystemPure)
import Seihou.Engine.Diff (computeDiff)
import Seihou.Manifest.Hash (hashContent)
import Seihou.Manifest.Types (emptyManifest)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)
tests :: IO TestTree
tests = testSpec "Seihou.Engine.Diff" spec
fixedTime :: UTCTime
fixedTime = parseTimeOrError True defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" "2026-03-01T10:30:00Z"
modName :: ModuleName
modName = ModuleName "test-module"
-- | Run diff computation in pure filesystem.
runDiff :: PureFS -> Manifest -> Set ModuleName -> [(FilePath, Text, ModuleName, Maybe PatchOp)] -> DiffResult
runDiff fs manifest activeModules planned =
fst $ runPureEff $ runFilesystemPure fs $ computeDiff manifest activeModules planned
-- | Helper to make a FileRecord from content.
mkRecord :: Text -> FileRecord
mkRecord content =
FileRecord
{ hash = hashContent content,
moduleName = modName,
strategy = Template,
generatedAt = fixedTime
}
-- | Helper to create a manifest with file records (avoids ambiguous record update).
manifestWithFiles :: Map.Map FilePath FileRecord -> Manifest
manifestWithFiles recs =
let base = emptyManifest fixedTime
in Manifest
{ version = base.version,
genAt = base.genAt,
modules = base.modules,
vars = base.vars,
files = recs
}
spec :: Spec
spec = do
let active = Set.singleton modName
describe "computeDiff" $ do
it "classifies file in plan only (not on disk) as New" $ do
let manifest = emptyManifest fixedTime
planned = [("README.md", "# Hello", modName, Nothing)]
result = runDiff emptyFS manifest active planned
length (result.new) `shouldBe` 1
(head result.new).path `shouldBe` "README.md"
it "classifies file in plan + on disk (not in manifest) as Conflict" $ do
let manifest = emptyManifest fixedTime
planned = [("README.md", "# Hello", modName, Nothing)]
fs = PureFS (Map.singleton "README.md" "existing content") mempty
result = runDiff fs manifest active planned
length (result.conflicts) `shouldBe` 1
(head result.conflicts).path `shouldBe` "README.md"
it "classifies patch op on existing file (not in manifest) as New, not Conflict" $ do
let manifest = emptyManifest fixedTime
planned = [(".gitignore", ".claude/\n", modName, Just AppendSection)]
fs = PureFS (Map.singleton ".gitignore" ".seihou/\n") mempty
result = runDiff fs manifest active planned
length (result.conflicts) `shouldBe` 0
length (result.new) `shouldBe` 1
(head result.new).path `shouldBe` ".gitignore"
it "classifies patch op on user-modified file (in manifest) as Modified, not Conflict" $ do
let originalContent = "original"
userContent = "user edited"
patchContent = "new section"
manifest = manifestWithFiles (Map.singleton "config.txt" (mkRecord originalContent))
planned = [("config.txt", patchContent, modName, Just AppendSection)]
fs = PureFS (Map.singleton "config.txt" userContent) mempty
result = runDiff fs manifest active planned
length (result.conflicts) `shouldBe` 0
length (result.modified) `shouldBe` 1
(head result.modified).path `shouldBe` "config.txt"
it "classifies file in manifest + plan + disk (unchanged) as Unchanged" $ do
let content = "# Hello World"
manifest = manifestWithFiles (Map.singleton "README.md" (mkRecord content))
planned = [("README.md", content, modName, Nothing)]
fs = PureFS (Map.singleton "README.md" content) mempty
result = runDiff fs manifest active planned
length (result.unchanged) `shouldBe` 1
head (result.unchanged) `shouldBe` "README.md"
it "classifies file in manifest + plan + disk (plan changed) as Modified" $ do
let oldContent = "# Hello"
newContent = "# Hello World"
manifest = manifestWithFiles (Map.singleton "README.md" (mkRecord oldContent))
planned = [("README.md", newContent, modName, Nothing)]
-- Disk matches manifest (user didn't touch it)
fs = PureFS (Map.singleton "README.md" oldContent) mempty
result = runDiff fs manifest active planned
length (result.modified) `shouldBe` 1
(head result.modified).path `shouldBe` "README.md"
(head result.modified).newContent `shouldBe` newContent
it "classifies file in manifest + plan + disk (user modified) as Conflict" $ do
let originalContent = "# Hello"
userContent = "# Hello - edited by user"
planContent = "# Hello World"
manifest = manifestWithFiles (Map.singleton "README.md" (mkRecord originalContent))
planned = [("README.md", planContent, modName, Nothing)]
-- Disk was modified by user (doesn't match manifest)
fs = PureFS (Map.singleton "README.md" userContent) mempty
result = runDiff fs manifest active planned
length (result.conflicts) `shouldBe` 1
(head result.conflicts).path `shouldBe` "README.md"
(head result.conflicts).planContent `shouldBe` planContent
it "classifies file in manifest only (on disk) as Orphaned" $ do
let content = "orphaned content"
manifest =
(emptyManifest fixedTime :: Manifest)
{ files = Map.singleton "old-file.txt" (mkRecord content)
}
planned = [] :: [(FilePath, Text, ModuleName, Maybe PatchOp)] -- module no longer produces this file
fs = PureFS (Map.singleton "old-file.txt" content) mempty
result = runDiff fs manifest active planned
length (result.orphaned) `shouldBe` 1
(head result.orphaned).path `shouldBe` "old-file.txt"
it "classifies file in manifest only (not on disk) as Orphaned" $ do
let content = "deleted content"
manifest =
(emptyManifest fixedTime :: Manifest)
{ files = Map.singleton "deleted.txt" (mkRecord content)
}
planned = [] :: [(FilePath, Text, ModuleName, Maybe PatchOp)]
result = runDiff emptyFS manifest active planned
length (result.orphaned) `shouldBe` 1
(head result.orphaned).path `shouldBe` "deleted.txt"
it "classifies file in manifest + plan (deleted from disk) as Modified" $ do
let content = "recreate me"
manifest =
(emptyManifest fixedTime :: Manifest)
{ files = Map.singleton "gone.txt" (mkRecord content)
}
planned = [("gone.txt", "new version", modName, Nothing)]
result = runDiff emptyFS manifest active planned
length (result.modified) `shouldBe` 1
(head result.modified).path `shouldBe` "gone.txt"
it "handles mixed classifications" $ do
let existingContent = "existing"
manifest =
(emptyManifest fixedTime :: Manifest)
{ files =
Map.fromList
[ ("unchanged.txt", mkRecord existingContent),
("orphaned.txt", mkRecord "orphan")
]
}
planned =
[ ("unchanged.txt", existingContent, modName, Nothing),
("new-file.txt", "brand new", modName, Nothing)
]
fs =
PureFS
(Map.fromList [("unchanged.txt", existingContent), ("orphaned.txt", "orphan")])
mempty
result = runDiff fs manifest active planned
length (result.new) `shouldBe` 1
length (result.unchanged) `shouldBe` 1
length (result.orphaned) `shouldBe` 1
length (result.modified) `shouldBe` 0
length (result.conflicts) `shouldBe` 0
it "handles empty manifest and empty plan" $ do
let manifest = emptyManifest fixedTime
result = runDiff emptyFS manifest Set.empty ([] :: [(FilePath, Text, ModuleName, Maybe PatchOp)])
result.new `shouldBe` []
result.modified `shouldBe` []
result.unchanged `shouldBe` []
result.conflicts `shouldBe` []
result.orphaned `shouldBe` []
it "does not classify files from inactive modules as orphaned" $ do
let otherMod = ModuleName "other-module"
content = "from other module"
record =
FileRecord
{ hash = hashContent content,
moduleName = otherMod,
strategy = Template,
generatedAt = fixedTime
}
manifest = manifestWithFiles (Map.singleton "other.txt" record)
planned = [("new.txt", "new content", modName, Nothing)]
activeModules = Set.singleton modName -- "test-module", NOT "other-module"
fs = PureFS (Map.singleton "other.txt" content) mempty
result = runDiff fs manifest activeModules planned
length (result.orphaned) `shouldBe` 0
length (result.new) `shouldBe` 1
it "classifies files from active modules as orphaned" $ do
let content = "active module content"
manifest = manifestWithFiles (Map.singleton "old.txt" (mkRecord content))
planned = [("new.txt", "new content", modName, Nothing)]
activeModules = Set.singleton modName -- file belongs to active module
fs = PureFS (Map.singleton "old.txt" content) mempty
result = runDiff fs manifest activeModules planned
length (result.orphaned) `shouldBe` 1
(head result.orphaned).path `shouldBe` "old.txt"
it "mixed active/inactive: only orphans active module's missing files" $ do
let otherMod = ModuleName "other-module"
otherRecord =
FileRecord
{ hash = hashContent "other content",
moduleName = otherMod,
strategy = Copy,
generatedAt = fixedTime
}
manifest =
manifestWithFiles
( Map.fromList
[ ("active-old.txt", mkRecord "active old"),
("other.txt", otherRecord)
]
)
-- active module now produces a different file, dropping active-old.txt
planned = [("active-new.txt", "active new", modName, Nothing)]
activeModules = Set.singleton modName
fs =
PureFS
(Map.fromList [("active-old.txt", "active old"), ("other.txt", "other content")])
mempty
result = runDiff fs manifest activeModules planned
-- active-old.txt is orphaned (active module no longer produces it)
length (result.orphaned) `shouldBe` 1
(head result.orphaned).path `shouldBe` "active-old.txt"
-- other.txt is invisible (inactive module), not orphaned
length (result.new) `shouldBe` 1
(head result.new).path `shouldBe` "active-new.txt"
it "plan targeting inactive module's file on disk is classified as Conflict" $ do
let otherMod = ModuleName "other-module"
otherRecord =
FileRecord
{ hash = hashContent "other content",
moduleName = otherMod,
strategy = Template,
generatedAt = fixedTime
}
manifest = manifestWithFiles (Map.singleton "shared.txt" otherRecord)
-- active module wants to write to same path owned by inactive module
planned = [("shared.txt", "new content from active", modName, Nothing)]
activeModules = Set.singleton modName
fs = PureFS (Map.singleton "shared.txt" "other content") mempty
result = runDiff fs manifest activeModules planned
-- File exists on disk but not in active manifest → Conflict
length (result.conflicts) `shouldBe` 1
(head result.conflicts).path `shouldBe` "shared.txt"
it "handles multiple active modules scoping independently" $ do
let modA = ModuleName "module-a"
modB = ModuleName "module-b"
modC = ModuleName "module-c"
mkRec m content =
FileRecord
{ hash = hashContent content,
moduleName = m,
strategy = Template,
generatedAt = fixedTime
}
manifest =
manifestWithFiles
( Map.fromList
[ ("from-a.txt", mkRec modA "a content"),
("from-b.txt", mkRec modB "b content"),
("from-c.txt", mkRec modC "c content")
]
)
-- Running modules A and B (not C); A still produces its file, B drops its file
planned = [("from-a.txt", "a content", modA, Nothing)]
activeModules = Set.fromList [modA, modB]
fs =
PureFS
( Map.fromList
[ ("from-a.txt", "a content"),
("from-b.txt", "b content"),
("from-c.txt", "c content")
]
)
mempty
result = runDiff fs manifest activeModules planned
-- from-a.txt unchanged (active, still produced)
length (result.unchanged) `shouldBe` 1
-- from-b.txt orphaned (active module B no longer produces it)
length (result.orphaned) `shouldBe` 1
(head result.orphaned).path `shouldBe` "from-b.txt"
-- from-c.txt invisible (inactive module C)
length (result.new) `shouldBe` 0
length (result.conflicts) `shouldBe` 0