packages feed

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