packages feed

seihou-core-0.6.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.Generics.Labels ()
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, Generic, 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, Generic, 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 ^. #steps)
      let cmdResults = map buildCommandOp (removal ^. #commands)
      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)
    & #files
    %~ Map.filter (\rec -> rec ^. #moduleName /= modName)
    & #genAt
    .~ now