packages feed

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

module Seihou.Engine.Plan
  ( compilePlan,
    parentDirs,
  )
where

import Control.Exception (IOException, SomeException, catch, try)
import Data.Aeson qualified as Aeson
import Data.Aeson.Encode.Pretty qualified as AesonPretty
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Text.IO qualified as TIO
import Data.Text.Lazy qualified as TL
import Data.Text.Lazy.Encoding qualified as TLE
import Data.Void (Void)
import Data.Yaml qualified as Yaml
import Dhall qualified
import Dhall.Core qualified as DhallCore
import Dhall.Src (Src)
import Seihou.Core.Expr (evalExpr)
import Seihou.Core.Path (validateProjectRelativePath)
import Seihou.Core.Types
import Seihou.Engine.DhallJSON (dhallExprToJSON)
import Seihou.Engine.Template (renderCommand, renderDestPath, renderTemplate, renderTemplateText)
import Seihou.Prelude
import System.FilePath (takeDirectory, takeExtension)

-- | Compile a module's steps into a list of filesystem operations.
-- Evaluates @when@ conditions, dispatches by strategy, reads source files,
-- renders templates, and expands destination paths.
compilePlan ::
  FilePath -> -- Module base directory (containing @files/@)
  Module ->
  Map VarName VarValue -> -- Resolved variable values
  IO (Either [Text] [Operation])
compilePlan baseDir modul vars = do
  let modName = modul.name
  results <- mapM (compileStep baseDir modName vars) modul.steps
  let (allErrors, allOps) = partitionResults results
  if null allErrors
    then case compileCommands vars modul.commands of
      Left cmdErrs -> pure (Left cmdErrs)
      Right cmdOps -> pure (Right (deduplicateDirs (concat allOps) ++ cmdOps))
    else pure (Left (concat allErrors))

-- | Compile commands into 'RunCommandOp' operations, interpolating
-- @{{var}}@ placeholders in the @run@ and @workDir@ fields.
-- Commands whose @when@ condition evaluates to False are skipped.
compileCommands :: Map VarName VarValue -> [Command] -> Either [Text] [Operation]
compileCommands vars = foldl' go (Right [])
  where
    go (Left errs) cmd = Left (errs ++ compileErrors cmd)
    go (Right ops) cmd =
      let shouldRun = case cmd.condition of
            Nothing -> True
            Just expr -> evalExpr vars expr
       in if shouldRun
            then case compileOneCommand vars cmd of
              Left cmdErrs -> Left cmdErrs
              Right op -> Right (ops ++ [op])
            else Right ops

    compileErrors cmd = case compileOneCommand vars cmd of
      Left es -> es
      Right _ -> []

-- | Compile a single command, interpolating placeholders in @run@ and @workDir@.
compileOneCommand :: Map VarName VarValue -> Command -> Either [Text] Operation
compileOneCommand vars cmd =
  case renderCommand cmd.run vars of
    Left placeholderErrors -> Left (map formatPlaceholderError placeholderErrors)
    Right runText ->
      case cmd.workDir of
        Nothing -> Right (RunCommandOp runText Nothing)
        Just wd ->
          case renderCommand wd vars of
            Left placeholderErrors -> Left (map formatPlaceholderError placeholderErrors)
            Right renderedWd ->
              case validateRenderedCommandWorkDir renderedWd of
                Left err -> Left [err]
                Right safeWd -> Right (RunCommandOp runText (Just (T.unpack safeWd)))

-- | Compile a single step into operations (or skip it).
-- If the step has a patch operation, it produces a 'PatchFileOp'; otherwise
-- dispatches by strategy to produce a 'WriteFileOp'.
compileStep ::
  FilePath ->
  ModuleName ->
  Map VarName VarValue ->
  Step ->
  IO (Either [Text] [Operation])
compileStep baseDir modName vars step = do
  -- Evaluate the when condition
  let shouldRun = case step.condition of
        Nothing -> True
        Just expr -> evalExpr vars expr
  if not shouldRun
    then pure (Right [])
    else case step.patch of
      Just _ -> compilePatchStep baseDir vars modName step
      Nothing -> case step.strategy of
        Copy -> compileCopyStep baseDir vars step
        Template -> compileTemplateStep baseDir vars step
        DhallText -> compileDhallTextStep baseDir vars step
        Structured -> compileStructuredStep baseDir vars step

-- | Compile a Copy step: read the source file and write it to the destination.
compileCopyStep ::
  FilePath ->
  Map VarName VarValue ->
  Step ->
  IO (Either [Text] [Operation])
compileCopyStep baseDir vars step = do
  let srcPath = baseDir </> "files" </> step.src
  result <- tryReadFile srcPath
  case result of
    Left err -> pure (Left [err])
    Right content ->
      case renderDestPath step.dest vars of
        Left placeholderErrors ->
          pure (Left (map formatPlaceholderError placeholderErrors))
        Right dest ->
          pure (writeFileOps dest content Copy)

-- | Compile a Template step: read, render placeholders, write.
compileTemplateStep ::
  FilePath ->
  Map VarName VarValue ->
  Step ->
  IO (Either [Text] [Operation])
compileTemplateStep baseDir vars step = do
  let srcPath = baseDir </> "files" </> step.src
  result <- tryReadFile srcPath
  case result of
    Left err -> pure (Left [err])
    Right content ->
      case renderTemplateText content vars of
        Left placeholderErrors ->
          pure (Left (map formatPlaceholderError placeholderErrors))
        Right rendered ->
          case renderDestPath step.dest vars of
            Left placeholderErrors ->
              pure (Left (map formatPlaceholderError placeholderErrors))
            Right dest ->
              pure (writeFileOps dest rendered Template)

-- | Compile a DhallText step: read source, substitute placeholders, evaluate as Dhall.
compileDhallTextStep ::
  FilePath ->
  Map VarName VarValue ->
  Step ->
  IO (Either [Text] [Operation])
compileDhallTextStep baseDir vars step = do
  let srcPath = baseDir </> "files" </> step.src
  result <- tryReadFile srcPath
  case result of
    Left err -> pure (Left [err])
    Right content ->
      case renderTemplate content vars of
        Left placeholderErrors ->
          pure (Left (map formatPlaceholderError placeholderErrors))
        Right substituted -> do
          dhallResult <- renderDhallText substituted
          case dhallResult of
            Left err -> pure (Left [err])
            Right evaluated ->
              case renderDestPath step.dest vars of
                Left placeholderErrors ->
                  pure (Left (map formatPlaceholderError placeholderErrors))
                Right dest ->
                  pure (writeFileOps dest evaluated DhallText)

-- | Compile a Structured step: read source, substitute placeholders, evaluate as Dhall,
-- then serialize to JSON or YAML based on the destination file extension.
compileStructuredStep ::
  FilePath ->
  Map VarName VarValue ->
  Step ->
  IO (Either [Text] [Operation])
compileStructuredStep baseDir vars step = do
  let srcPath = baseDir </> "files" </> step.src
  result <- tryReadFile srcPath
  case result of
    Left err -> pure (Left [err])
    Right content ->
      case renderTemplate content vars of
        Left placeholderErrors ->
          pure (Left (map formatPlaceholderError placeholderErrors))
        Right substituted -> do
          dhallResult <- evaluateDhallExpr substituted
          case dhallResult of
            Left err -> pure (Left [err])
            Right dhallExpr ->
              case dhallExprToJSON dhallExpr of
                Left err -> pure (Left [err])
                Right jsonValue ->
                  case renderDestPath step.dest vars of
                    Left placeholderErrors ->
                      pure (Left (map formatPlaceholderError placeholderErrors))
                    Right dest ->
                      case validateRenderedDestination dest of
                        Left err -> pure (Left [err])
                        Right safeDest ->
                          case serializeByExtension (T.unpack safeDest) jsonValue of
                            Left err -> pure (Left [err])
                            Right serialized -> pure (Right (writeFileOpsUnchecked safeDest serialized Structured))

-- | Compile a patch step: read source, render template, produce PatchFileOp.
-- The content rendering follows the step's strategy (Template renders placeholders,
-- Copy uses raw content, DhallText evaluates as Dhall).
compilePatchStep ::
  FilePath ->
  Map VarName VarValue ->
  ModuleName ->
  Step ->
  IO (Either [Text] [Operation])
compilePatchStep baseDir vars modName step = do
  let srcPath = baseDir </> "files" </> step.src
      patchOp' = case step.patch of
        Just p -> p
        Nothing -> error "compilePatchStep called without patch op"
  result <- tryReadFile srcPath
  case result of
    Left err -> pure (Left [err])
    Right rawContent -> do
      -- Render content based on strategy
      contentResult <- case step.strategy of
        Copy -> pure (Right rawContent)
        Template ->
          pure $ case renderTemplateText rawContent vars of
            Left placeholderErrors -> Left (map formatPlaceholderError placeholderErrors)
            Right rendered -> Right rendered
        DhallText -> do
          case renderTemplate rawContent vars of
            Left placeholderErrors -> pure (Left (map formatPlaceholderError placeholderErrors))
            Right substituted -> do
              dhallResult <- renderDhallText substituted
              case dhallResult of
                Left err -> pure (Left [err])
                Right evaluated -> pure (Right evaluated)
        Structured ->
          pure (Left ["Structured strategy cannot be used with patch operations"])
      case contentResult of
        Left errs -> pure (Left errs)
        Right content ->
          case renderDestPath step.dest vars of
            Left placeholderErrors ->
              pure (Left (map formatPlaceholderError placeholderErrors))
            Right dest ->
              pure (patchFileOps dest content patchOp' step.strategy modName)

-- | Evaluate a Dhall expression and return the normalized AST.
evaluateDhallExpr :: Text -> IO (Either Text (DhallCore.Expr Src Void))
evaluateDhallExpr dhallSource = do
  result <- try (Dhall.inputExpr dhallSource)
  case result of
    Left (e :: SomeException) ->
      pure (Left ("Dhall evaluation failed: " <> T.pack (show e)))
    Right expr -> pure (Right expr)

-- | Serialize a JSON Value to Text based on the destination file extension.
-- .json → pretty-printed JSON; .yaml or .yml → YAML; other → error.
serializeByExtension :: FilePath -> Aeson.Value -> Either Text Text
serializeByExtension dest value =
  case takeExtension dest of
    ".json" -> Right (TL.toStrict (TLE.decodeUtf8 (AesonPretty.encodePretty value)) <> "\n")
    ".yaml" -> Right (TE.decodeUtf8 (Yaml.encode value))
    ".yml" -> Right (TE.decodeUtf8 (Yaml.encode value))
    ext -> Left ("Structured strategy: unsupported output format '" <> T.pack ext <> "' (expected .json, .yaml, or .yml)")

-- | Evaluate a Dhall expression that produces Text.
renderDhallText :: Text -> IO (Either Text Text)
renderDhallText dhallSource = do
  result <- try (Dhall.input Dhall.strictText dhallSource)
  case result of
    Left (e :: SomeException) ->
      pure (Left ("Dhall evaluation failed: " <> T.pack (show e)))
    Right t -> pure (Right t)

validateRenderedDestination :: Text -> Either Text Text
validateRenderedDestination dest =
  case validateProjectRelativePath dest of
    Left err -> Left ("rendered destination " <> err)
    Right safePath -> Right (T.pack safePath)

validateRenderedCommandWorkDir :: Text -> Either Text Text
validateRenderedCommandWorkDir workDir =
  case validateProjectRelativePath workDir of
    Left err -> Left ("rendered command workDir " <> err)
    Right safePath -> Right (T.pack safePath)

writeFileOps :: Text -> Text -> Strategy -> Either [Text] [Operation]
writeFileOps dest content strategy =
  case validateRenderedDestination dest of
    Left err -> Left [err]
    Right safeDest -> Right (writeFileOpsUnchecked safeDest content strategy)

writeFileOpsUnchecked :: Text -> Text -> Strategy -> [Operation]
writeFileOpsUnchecked dest content strategy =
  let destStr = T.unpack dest
      dirOps = map (CreateDirOp . T.unpack) (parentDirs dest)
   in dirOps ++ [WriteFileOp destStr content strategy]

patchFileOps :: Text -> Text -> PatchOp -> Strategy -> ModuleName -> Either [Text] [Operation]
patchFileOps dest content patchOp' strategy modName =
  case validateRenderedDestination dest of
    Left err -> Left [err]
    Right safeDest ->
      let destStr = T.unpack safeDest
          dirOps = map (CreateDirOp . T.unpack) (parentDirs safeDest)
       in Right (dirOps ++ [PatchFileOp destStr content patchOp' strategy modName])

-- | Extract parent directories from a path.
-- @"src/Lib.hs"@ produces @["src"]@.
-- @"a/b/c.txt"@ produces @["a", "a/b"]@.
parentDirs :: Text -> [Text]
parentDirs path =
  let dir = T.pack (takeDirectory (T.unpack path))
   in if dir == "." || T.null dir
        then []
        else buildChain dir

-- | Build the chain of parent directories.
-- @"a/b"@ produces @["a", "a/b"]@.
buildChain :: Text -> [Text]
buildChain dir =
  let parts = T.splitOn "/" dir
      prefixes = drop 1 (scanl (\acc p -> acc <> "/" <> p) "" parts)
      trimmed = map (T.drop 1) prefixes -- remove leading /
   in trimmed

-- | Try to read a file, returning an error message on failure.
tryReadFile :: FilePath -> IO (Either Text Text)
tryReadFile path =
  (Right <$> TIO.readFile path) `catch` handler
  where
    handler :: IOException -> IO (Either Text Text)
    handler e = pure (Left ("failed to read file: " <> T.pack path <> ": " <> T.pack (show e)))

-- | Format a placeholder error as a human-readable text message.
formatPlaceholderError :: PlaceholderError -> Text
formatPlaceholderError (UnresolvedPlaceholder (VarName name) lineNum) =
  "unresolved placeholder '{{" <> name <> "}}' at line " <> T.pack (show lineNum)
formatPlaceholderError (MalformedPlaceholder raw lineNum) =
  "malformed placeholder '" <> raw <> "' at line " <> T.pack (show lineNum)
formatPlaceholderError (UnterminatedIf lineNum) =
  "unterminated {{#if}} opened at line " <> T.pack (show lineNum)
formatPlaceholderError (OrphanBlockToken tok lineNum) =
  "stray " <> tok <> " at line " <> T.pack (show lineNum)
formatPlaceholderError (MalformedIfExpression expr lineNum parseErr) =
  "malformed {{#if}} expression '" <> expr <> "' at line " <> T.pack (show lineNum) <> ": " <> parseErr

-- | Deduplicate CreateDirOp operations while preserving order.
deduplicateDirs :: [Operation] -> [Operation]
deduplicateDirs = go Set.empty
  where
    go _ [] = []
    go seen (op@(CreateDirOp p) : rest)
      | p `Set.member` seen = go seen rest
      | otherwise = op : go (Set.insert p seen) rest
    go seen (op : rest) = op : go seen rest

-- | Partition a list of Either into errors and successes.
partitionResults :: [Either e a] -> ([e], [a])
partitionResults = foldr step ([], [])
  where
    step (Left e) (errs, oks) = (e : errs, oks)
    step (Right a) (errs, oks) = (errs, a : oks)