packages feed

seihou-core-0.4.0.0: src/Seihou/Engine/Remove.hs

module Seihou.Engine.Remove
  ( RemovalFile (..),
    RemovalPlan (..),
    RemovalError (..),
    RemovalOp (..),
    RemovalFileStatus (..),
    ExecutedRemovalPlan (..),
    computeRemovalPlan,
    executeRemoval,
    buildRemovalOps,
    executeRemovalOps,
  )
where

import Control.Monad (foldM)
import Data.List (nub, sortBy)
import Data.Map.Strict qualified as Map
import Data.Ord (Down (..))
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Time (UTCTime)
import Seihou.Core.Path (validateProjectRelativePath)
import Seihou.Core.Types
import Seihou.Effect.Filesystem (Filesystem, doesFileExist, readFileText, removeDirectoryIfEmpty, removeFile, writeFileText)
import Seihou.Engine.Section (removeSection)
import Seihou.Manifest.Hash (hashContent)
import Seihou.Prelude
import System.FilePath (takeDirectory)

-- ============================================================
-- Legacy types (used by current CLI handler, Milestone 4 replaces)
-- ============================================================

-- | Classification of a file during removal planning.
data RemovalFile
  = -- | Disk hash matches manifest hash — safe to delete.
    RemovalSafe FilePath
  | -- | User modified the file since generation — needs confirmation.
    RemovalConflict FilePath
  | -- | File was already deleted from disk.
    RemovalGone FilePath
  deriving stock (Eq, Show)

-- | A plan describing what files to remove for a given module.
data RemovalPlan = RemovalPlan
  { targetModule :: ModuleName,
    files :: [RemovalFile]
  }
  deriving stock (Eq, Show)

-- ============================================================
-- New step-based removal types
-- ============================================================

-- | A concrete removal operation ready to execute.
data RemovalOp
  = -- | Delete a file, with its current status.
    DeleteFileOp FilePath RemovalFileStatus
  | -- | Strip this module's section markers from a file.
    StripSectionOp FilePath
  | -- | Apply a Dhall text function to rewrite a file.
    RewriteOp FilePath FilePath
  | -- | Run a shell command during removal.
    RemovalCommandOp Text (Maybe Text)
  deriving stock (Eq, Show)

-- | Status of a file targeted for deletion.
data RemovalFileStatus
  = -- | Disk hash matches manifest — safe to delete.
    RFSafe
  | -- | User modified the file since generation.
    RFConflict
  | -- | File already deleted from disk.
    RFGone
  deriving stock (Eq, Show)

-- | A removal plan built from declared removal steps.
data ExecutedRemovalPlan = ExecutedRemovalPlan
  { targetModule :: ModuleName,
    ops :: [RemovalOp]
  }
  deriving stock (Eq, Show)

-- | Errors that prevent removal.
data RemovalError
  = -- | The module is not in the manifest's applied modules list.
    ModuleNotApplied ModuleName
  | -- | The module has no removal specification.
    ModuleNotRemovable ModuleName
  | -- | A module-declared removal path would escape the project root.
    RemovalUnsafePath Text Text Text
  deriving stock (Eq, Show)

-- ============================================================
-- Legacy compute/execute (preserves current CLI behavior)
-- ============================================================

-- | Compute a removal plan for the given module.
-- Checks that the module is applied and has a removal spec, then classifies
-- each file it owns as safe, conflicted, or already gone.
computeRemovalPlan ::
  (Filesystem :> es) =>
  Manifest ->
  ModuleName ->
  Eff es (Either RemovalError RemovalPlan)
computeRemovalPlan manifest modName = do
  case findApplied manifest modName of
    Nothing -> pure (Left (ModuleNotApplied modName))
    Just am
      | Nothing <- am.removal -> pure (Left (ModuleNotRemovable modName))
      | otherwise -> do
          let ownedFiles = moduleFiles manifest modName
          classified <- mapM classifyForRemoval ownedFiles
          pure (Right (RemovalPlan {targetModule = modName, files = classified}))

-- | Execute a removal plan: delete files, clean up empty directories,
-- and return the updated manifest.
executeRemoval ::
  (Filesystem :> es) =>
  Manifest ->
  RemovalPlan ->
  Set FilePath ->
  UTCTime ->
  Eff es Manifest
executeRemoval manifest plan keepSet now = do
  let toDelete = filesToDelete plan keepSet
  mapM_ removeFile toDelete
  cleanupEmptyDirs toDelete
  pure (removeFromManifest manifest plan.targetModule now)

-- ============================================================
-- New step-based removal engine
-- ============================================================

-- | Build a list of removal operations from declared removal steps.
-- Classifies remove-file targets by checking the manifest and disk state.
buildRemovalOps ::
  (Filesystem :> es) =>
  Manifest ->
  ModuleName ->
  Removal ->
  Eff es (Either RemovalError ExecutedRemovalPlan)
buildRemovalOps manifest modName removal = do
  case findApplied manifest modName of
    Nothing -> pure (Left (ModuleNotApplied modName))
    Just _ -> do
      stepResults <- mapM (buildStepOp manifest modName) removal.removalSteps
      let cmdResults = map buildCommandOp removal.removalCommands
      pure $ do
        stepOps <- sequence stepResults
        cmdOps <- sequence cmdResults
        Right
          ExecutedRemovalPlan
            { targetModule = modName,
              ops = stepOps ++ cmdOps
            }

-- | Build a single removal operation from a removal step.
buildStepOp ::
  (Filesystem :> es) =>
  Manifest ->
  ModuleName ->
  RemovalStep ->
  Eff es (Either RemovalError RemovalOp)
buildStepOp manifest _modName step = case step.action of
  RemoveFileAction ->
    case validateRemovalPath "remove-file destination" step.dest of
      Left err -> pure (Left err)
      Right path -> do
        status <- classifyFileStatus manifest path
        pure (Right (DeleteFileOp path status))
  RemoveSectionAction ->
    pure $
      case validateRemovalPath "remove-section destination" step.dest of
        Left err -> Left err
        Right path -> Right (StripSectionOp path)
  RewriteFileAction ->
    pure $ do
      dest <- validateRemovalPath "rewrite-file destination" step.dest
      src <- case step.src of
        Just s -> validateRemovalPath "rewrite-file source" (T.pack s)
        Nothing -> Left (RemovalUnsafePath "rewrite-file source" "" "path must not be empty")
      Right (RewriteOp dest src)

buildCommandOp :: Command -> Either RemovalError RemovalOp
buildCommandOp command =
  case traverse (validateRemovalPath "remove-command workDir") command.workDir of
    Left err -> Left err
    Right safeWorkDir -> Right (RemovalCommandOp command.run (fmap T.pack safeWorkDir))

validateRemovalPath :: Text -> Text -> Either RemovalError FilePath
validateRemovalPath label path =
  case validateProjectRelativePath path of
    Left reason -> Left (RemovalUnsafePath label path reason)
    Right safePath -> Right safePath

-- | Classify a file's status for removal by comparing disk to manifest.
classifyFileStatus ::
  (Filesystem :> es) =>
  Manifest ->
  FilePath ->
  Eff es RemovalFileStatus
classifyFileStatus manifest path = do
  exists <- doesFileExist path
  if not exists
    then pure RFGone
    else case Map.lookup path manifest.files of
      Nothing -> pure RFSafe -- Not in manifest, treat as safe to delete
      Just rec -> do
        content <- readFileText path
        let diskHash = hashContent content
        if diskHash == rec.hash
          then pure RFSafe
          else pure RFConflict

-- | Execute a list of removal operations and return the updated manifest.
executeRemovalOps ::
  (Filesystem :> es) =>
  Manifest ->
  ExecutedRemovalPlan ->
  Set FilePath ->
  UTCTime ->
  Eff es Manifest
executeRemovalOps manifest plan keepSet now = do
  let modName = plan.targetModule
  deletedPaths <- foldM (execOp modName keepSet) [] plan.ops
  cleanupEmptyDirs deletedPaths
  pure (removeFromManifest manifest modName now)

-- | Execute a single removal operation. Returns accumulated deleted paths.
execOp ::
  (Filesystem :> es) =>
  ModuleName ->
  Set FilePath ->
  [FilePath] ->
  RemovalOp ->
  Eff es [FilePath]
execOp _ keepSet acc (DeleteFileOp path status) =
  if Set.member path keepSet
    then pure acc
    else case status of
      RFSafe -> do
        removeFile path
        pure (path : acc)
      RFConflict -> do
        -- Conflicts are deleted unless in keepSet (handled by CLI)
        removeFile path
        pure (path : acc)
      RFGone -> pure acc
execOp modName _ acc (StripSectionOp path) = do
  exists <- doesFileExist path
  if exists
    then do
      content <- readFileText path
      let prefix = guessCommentPrefix path
          cleaned = removeSection modName prefix content
      writeFileText path cleaned
      pure acc
    else pure acc
execOp _ _ acc (RewriteOp _ _) = do
  -- RewriteFileAction is deferred to a future milestone (requires Dhall eval)
  pure acc
execOp _ _ acc (RemovalCommandOp _ _) = do
  -- Commands are executed by the CLI handler, not the engine
  pure acc

-- | Guess the comment prefix for a file based on its extension.
guessCommentPrefix :: FilePath -> Text
guessCommentPrefix path
  | ".hs" `T.isSuffixOf` T.pack path = "--"
  | ".cabal" `T.isSuffixOf` T.pack path = "--"
  | ".yaml" `T.isSuffixOf` T.pack path = "#"
  | ".yml" `T.isSuffixOf` T.pack path = "#"
  | ".toml" `T.isSuffixOf` T.pack path = "#"
  | ".nix" `T.isSuffixOf` T.pack path = "#"
  | otherwise = "#"

-- ============================================================
-- Shared helpers
-- ============================================================

-- | Find an applied module by name.
findApplied :: Manifest -> ModuleName -> Maybe AppliedModule
findApplied manifest modName =
  case filter (\am -> am.name == modName) manifest.modules of
    (am : _) -> Just am
    [] -> Nothing

-- | Get the file paths owned by a module in the manifest.
moduleFiles :: Manifest -> ModuleName -> [(FilePath, FileRecord)]
moduleFiles manifest modName =
  [ (path, rec)
  | (path, rec) <- Map.toList manifest.files,
    rec.moduleName == modName
  ]

-- | Classify a single file for removal (legacy).
classifyForRemoval :: (Filesystem :> es) => (FilePath, FileRecord) -> Eff es RemovalFile
classifyForRemoval (path, rec) = do
  exists <- doesFileExist path
  if not exists
    then pure (RemovalGone path)
    else do
      content <- readFileText path
      let diskHash = hashContent content
      if diskHash == rec.hash
        then pure (RemovalSafe path)
        else pure (RemovalConflict path)

-- | Determine which files should actually be deleted.
filesToDelete :: RemovalPlan -> Set FilePath -> [FilePath]
filesToDelete plan keepSet =
  [ path
  | rf <- plan.files,
    let path = removalFilePath rf,
    shouldDelete rf,
    not (Set.member path keepSet)
  ]
  where
    shouldDelete (RemovalSafe _) = True
    shouldDelete (RemovalConflict _) = True
    shouldDelete (RemovalGone _) = False

-- | Extract the path from a RemovalFile.
removalFilePath :: RemovalFile -> FilePath
removalFilePath (RemovalSafe p) = p
removalFilePath (RemovalConflict p) = p
removalFilePath (RemovalGone p) = p

-- | After deleting files, try to remove their now-empty parent directories.
-- Walks parents bottom-up (deepest first) to correctly cascade.
cleanupEmptyDirs :: (Filesystem :> es) => [FilePath] -> Eff es ()
cleanupEmptyDirs paths = do
  let parentDirs = nub $ concatMap allParents paths
      -- Sort deepest-first so children are removed before parents
      sorted = sortBy (\a b -> compare (Down (length a)) (Down (length b))) parentDirs
  mapM_ removeDirectoryIfEmpty sorted

-- | Get all parent directories of a path, excluding "." and "".
allParents :: FilePath -> [FilePath]
allParents path = go (takeDirectory path)
  where
    go "." = []
    go "" = []
    go "/" = []
    go dir = dir : go (takeDirectory dir)

-- | Remove a module and its files from the manifest.
removeFromManifest :: Manifest -> ModuleName -> UTCTime -> Manifest
removeFromManifest manifest modName now =
  manifest
    { modules = filter (\am -> am.name /= modName) manifest.modules,
      files = Map.filter (\rec -> rec.moduleName /= modName) manifest.files,
      genAt = now
    }