packages feed

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

module Seihou.Engine.Preview
  ( FileStatus (..),
    PreviewLine (..),
    buildPreview,
    renderPreviewPlain,
    formatPlanView,
  )
where

import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as T
import Seihou.Core.Types
import Seihou.Prelude

-- | Status of a file relative to the manifest and disk.
data FileStatus
  = FsNew
  | FsModified
  | FsUnchanged
  | FsConflict
  | FsOrphaned
  | FsUnknown
  deriving stock (Eq, Show)

-- | One line in the dry-run preview.
data PreviewLine
  = FilePreview
      { previewStatus :: FileStatus,
        previewPath :: FilePath,
        previewAnnotation :: Text,
        previewModule :: Maybe ModuleName
      }
  | DirPreview FilePath
  | CommandPreview Text
  | OrphanPreview FilePath ModuleName
  deriving stock (Eq, Show)

-- | Build a structured preview from operations and an optional diff result.
-- The ownership map tracks which module produced each file path.
-- When no DiffResult is provided (first run, no manifest), all file
-- operations are treated as new.
buildPreview :: [Operation] -> Maybe DiffResult -> Map FilePath ModuleName -> [PreviewLine]
buildPreview ops mDiff ownerMap =
  let opLines = map (opToPreview mDiff ownerMap) ops
      orphanLines = case mDiff of
        Nothing -> []
        Just diff ->
          -- Only include orphans whose path is NOT produced by any operation
          let producedPaths = Set.fromList [p | op <- ops, Just p <- [destOfOp op]]
           in [ OrphanPreview o.path o.moduleName
              | o <- diff.orphaned,
                not (Set.member o.path producedPaths)
              ]
   in opLines ++ orphanLines

-- | Convert a single operation to a preview line.
opToPreview :: Maybe DiffResult -> Map FilePath ModuleName -> Operation -> PreviewLine
opToPreview mDiff ownerMap (WriteFileOp dest _ strat) =
  FilePreview
    { previewStatus = lookupStatus dest mDiff,
      previewPath = dest,
      previewAnnotation = strategyName strat,
      previewModule = Map.lookup dest ownerMap
    }
opToPreview _ _ (CreateDirOp path) = DirPreview path
opToPreview mDiff ownerMap (CopyFileOp _ dest) =
  FilePreview
    { previewStatus = lookupStatus dest mDiff,
      previewPath = dest,
      previewAnnotation = "copy",
      previewModule = Map.lookup dest ownerMap
    }
opToPreview _ _ (RunCommandOp cmd _) = CommandPreview cmd
opToPreview mDiff ownerMap (PatchFileOp dest _ _patchOp' _ modName') =
  FilePreview
    { previewStatus = lookupStatus dest mDiff,
      previewPath = dest,
      previewAnnotation = "patch",
      previewModule = Just modName'
    }

-- | Look up a file's status in the diff result.
lookupStatus :: FilePath -> Maybe DiffResult -> FileStatus
lookupStatus _ Nothing = FsNew
lookupStatus path (Just diff)
  | any (\f -> f.path == path) diff.new = FsNew
  | any (\f -> f.path == path) diff.modified = FsModified
  | path `elem` diff.unchanged = FsUnchanged
  | any (\f -> f.path == path) diff.conflicts = FsConflict
  | any (\f -> f.path == path) diff.orphaned = FsOrphaned
  | otherwise = FsUnknown

-- | Render preview lines as plain text (no ANSI codes).
renderPreviewPlain :: [PreviewLine] -> Text
renderPreviewPlain lines' =
  if null lines'
    then "No operations to perform.\n"
    else T.unlines (map (renderPlainLine maxPathLen) fileLines ++ map renderNonFileLine nonFileLines)
  where
    fileLines = [l | l@(FilePreview {}) <- lines']
    nonFileLines = [l | l <- lines', not (isFileLine l)]
    maxPathLen = maximum (0 : map (T.length . T.pack . (.previewPath)) fileLines)

renderPlainLine :: Int -> PreviewLine -> Text
renderPlainLine maxPath (FilePreview status path annotation mMod) =
  let pathText = T.pack path
      pathPad = T.replicate (maxPath - T.length pathText) " "
      modSuffix = case mMod of
        Just mn -> ", " <> mn.unModuleName
        Nothing -> ""
   in "    " <> statusTag status <> "  " <> pathText <> pathPad <> "  (" <> annotation <> modSuffix <> ")"
renderPlainLine _ other = renderNonFileLine other

renderNonFileLine :: PreviewLine -> Text
renderNonFileLine (DirPreview path) =
  "    mkdir  " <> T.pack path
renderNonFileLine (CommandPreview cmd) =
  "    run    " <> cmd
renderNonFileLine (OrphanPreview path modName') =
  "    [orphaned]  " <> T.pack path <> "  (orphaned from " <> modName'.unModuleName <> ")"
renderNonFileLine _ = ""

isFileLine :: PreviewLine -> Bool
isFileLine (FilePreview {}) = True
isFileLine _ = False

statusTag :: FileStatus -> Text
statusTag FsNew = "[new]"
statusTag FsModified = "[modified]"
statusTag FsUnchanged = "[unchanged]"
statusTag FsConflict = "[conflict]"
statusTag FsOrphaned = "[orphaned]"
statusTag FsUnknown = "[unknown]"

strategyName :: Strategy -> Text
strategyName Copy = "copy"
strategyName Template = "template"
strategyName DhallText = "dhall-text"
strategyName Structured = "structured"

-- | Format a complete plan view with header, variables, operations, and summary.
formatPlanView :: [ModuleName] -> Map VarName VarValue -> [PreviewLine] -> DiffResult -> Text
formatPlanView moduleNames vars preview diff =
  T.unlines $
    [header, ""]
      ++ varsSection
      ++ ["  Operations:"]
      ++ map ("  " <>) (T.lines (renderPreviewPlain preview))
      ++ [""]
      ++ [summaryText]
  where
    header =
      "Generation Plan ("
        <> T.intercalate " + " (map (.unModuleName) moduleNames)
        <> "):"

    varsSection =
      if Map.null vars
        then []
        else
          "  Variables:"
            : map formatVar (Map.toAscList vars)
            ++ [""]

    maxVarLen = maximum (0 : map (\(VarName n, _) -> T.length n) (Map.toAscList vars))

    formatVar (VarName name, val) =
      let namePad = T.replicate (maxVarLen - T.length name) " "
       in "    " <> name <> namePad <> " = " <> showVarValue val

    showVarValue (VText t) = "\"" <> t <> "\""
    showVarValue (VBool True) = "true"
    showVarValue (VBool False) = "false"
    showVarValue (VInt n) = T.pack (show n)
    showVarValue (VList vs) = "[" <> T.intercalate ", " (map showVarValue vs) <> "]"

    nFiles = length diff.new + length diff.modified
    nConflicts = length diff.conflicts
    summaryText =
      "  "
        <> T.pack (show nFiles)
        <> " files to write, "
        <> T.pack (show nConflicts)
        <> " conflicts"

-- | Extract the destination path from a file-producing operation.
destOfOp :: Operation -> Maybe FilePath
destOfOp (WriteFileOp d _ _) = Just d
destOfOp (CopyFileOp _ d) = Just d
destOfOp (PatchFileOp d _ _ _ _) = Just d
destOfOp _ = Nothing