seihou-core-0.4.0.0: src/Seihou/Engine/Diff.hs
module Seihou.Engine.Diff
( computeDiff,
planToFileMap,
)
where
import Data.Map.Strict qualified as Map
import Data.Set (Set)
import Data.Set qualified as Set
import Seihou.Core.Types
import Seihou.Effect.Filesystem (Filesystem, doesFileExist, readFileText)
import Seihou.Manifest.Hash (hashContent)
import Seihou.Prelude
-- | Extract a map of (path, content) from planned operations.
-- Only WriteFileOp and CopyFileOp produce file entries; directories and
-- commands are ignored.
planToFileMap :: [Operation] -> Map FilePath Text
planToFileMap = Map.fromList . concatMap extract
where
extract (WriteFileOp dest content _) = [(dest, content)]
extract (PatchFileOp dest content _ _ _) = [(dest, content)]
extract _ = []
-- | Compute a three-state diff: manifest vs plan vs disk.
-- Classifies each file into one of: New, Modified, Unchanged, Conflict, Orphaned.
-- Patch operations (append-section, append-file, prepend-file) are never
-- classified as conflicts because they are designed to merge with existing files.
--
-- The @activeModules@ parameter scopes orphan detection: only manifest files
-- owned by a module in this set can be classified as Orphaned. Files from
-- other modules are invisible to the diff, preserving them across independent
-- module runs.
computeDiff ::
(Filesystem :> es) =>
Manifest ->
Set ModuleName ->
[(FilePath, Text, ModuleName, Maybe PatchOp)] ->
Eff es DiffResult
computeDiff manifest activeModules planned = do
let manifestFiles' = manifest.files
activeManifestFiles =
Map.filter (\r -> r.moduleName `Set.member` activeModules) manifestFiles'
planMap = Map.fromList [(p, (content, modName, patchOp)) | (p, content, modName, patchOp) <- planned]
allPaths =
Set.toList $
Set.union
(Map.keysSet activeManifestFiles)
(Map.keysSet planMap)
results <- mapM (classifyFile activeManifestFiles planMap) allPaths
let newFiles = [f | ClassNew f <- results]
modifiedFiles = [f | ClassModified f <- results]
unchangedFiles = [f | ClassUnchanged f <- results]
conflictFiles = [f | ClassConflict f <- results]
orphanedFiles = [f | ClassOrphaned f <- results]
pure
DiffResult
{ new = newFiles,
modified = modifiedFiles,
unchanged = unchangedFiles,
conflicts = conflictFiles,
orphaned = orphanedFiles
}
data Classification
= ClassNew PlannedFile
| ClassModified ModifiedFile
| ClassUnchanged FilePath
| ClassConflict ConflictFile
| ClassOrphaned OrphanedFile
classifyFile ::
(Filesystem :> es) =>
Map FilePath FileRecord ->
Map FilePath (Text, ModuleName, Maybe PatchOp) ->
FilePath ->
Eff es Classification
classifyFile manifestFiles' planMap path = do
let inManifest = Map.lookup path manifestFiles'
inPlan = Map.lookup path planMap
isPatch = case inPlan of
Just (_, _, Just _) -> True
_ -> False
onDisk <- doesFileExist path
case (inManifest, inPlan, onDisk) of
-- In plan, not in manifest, not on disk → New
(Nothing, Just (content, modName, _), False) ->
pure $ ClassNew (PlannedFile {path = path, moduleName = modName, content = content})
-- In plan, not in manifest, on disk → Patch ops merge with existing files (New);
-- non-patch ops conflict (file exists but wasn't generated by us)
(Nothing, Just (content, modName, _), True)
| isPatch ->
pure $ ClassNew (PlannedFile {path = path, moduleName = modName, content = content})
| otherwise -> do
diskContent <- readFileText path
let diskHash = hashContent diskContent
pure $
ClassConflict
( ConflictFile
{ path = path,
moduleName = modName,
manifestHash = SHA256 "",
diskHash = diskHash,
planContent = content
}
)
-- In manifest + plan + disk
(Just record, Just (content, modName, _), True) -> do
diskContent <- readFileText path
let diskHash = hashContent diskContent
manifestHash = record.hash
planHash = hashContent content
if diskHash /= manifestHash
then
-- Patch ops always merge, even if the user modified the file
if isPatch
then
pure $
ClassModified
( ModifiedFile
{ path = path,
moduleName = modName,
oldHash = manifestHash,
newContent = content
}
)
else -- User modified the file → Conflict
pure $
ClassConflict
( ConflictFile
{ path = path,
moduleName = modName,
manifestHash = manifestHash,
diskHash = diskHash,
planContent = content
}
)
else
if planHash == manifestHash
then pure $ ClassUnchanged path
else
pure $
ClassModified
( ModifiedFile
{ path = path,
moduleName = modName,
oldHash = manifestHash,
newContent = content
}
)
-- In manifest + plan, not on disk → treat as Modified (file was deleted by user, re-create)
(Just record, Just (content, modName, _), False) ->
pure $
ClassModified
( ModifiedFile
{ path = path,
moduleName = modName,
oldHash = record.hash,
newContent = content
}
)
-- In manifest, not in plan, on disk → Orphaned
(Just record, Nothing, True) ->
pure $ ClassOrphaned (OrphanedFile {path = path, moduleName = record.moduleName})
-- In manifest, not in plan, not on disk → Orphaned (already deleted)
(Just record, Nothing, False) ->
pure $ ClassOrphaned (OrphanedFile {path = path, moduleName = record.moduleName})
-- Not in manifest, not in plan → shouldn't happen (we only iterate known paths)
(Nothing, Nothing, _) ->
pure $ ClassUnchanged path -- unreachable in practice