packages feed

seihou-core-0.4.0.0: src/Seihou/Dhall/Eval.hs

module Seihou.Dhall.Eval
  ( evalDhallExpr,
    evalModuleFromFile,
    evalRecipeFromFile,
    evalBlueprintFromFile,
    evalAgentPromptFromFile,
    evalRegistryFromFile,
    moduleDecoder,
    recipeDecoder,
    blueprintDecoder,
    agentPromptDecoder,
    commandVarDecoder,
    promptGuidanceDecoder,
    agentPromptLaunchDecoder,
    blueprintFileDecoder,
    registryDecoder,
    registryEntryDecoder,
    varTypeDecoder,
    varDeclDecoder,
    varExportDecoder,
    promptDecoder,
    stepDecoder,
    commandDecoder,
    strategyDecoder,
    patchOpDecoder,
    dependencyDecoder,
    removalDecoder,
    removalStepDecoder,
    removalActionDecoder,
    migrationDecoder,
    migrationOpDecoder,
  )
where

import Control.Exception (SomeException, evaluate, throwIO, try)
import Data.Either.Validation (Validation (..))
import Data.List (foldl')
import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Data.Void (Void)
import Dhall (defaultInputSettings, input, inputExprWithSettings, inputFile, list, record, rootDirectory, sourceName, strictText)
import Dhall.Core (Chunks (..), makeRecordField)
import Dhall.Core qualified as Dhall (Expr (..))
import Dhall.Map qualified as DhallMap
import Dhall.Marshal.Decode (Decoder (..), Extractor, bool, constructor, field, maybe, natural, string, union)
import Dhall.Src (Src)
import Seihou.Core.Expr (parseExpr)
import Seihou.Core.Migration (Migration (..), MigrationOp (..))
import Seihou.Core.Registry (Registry (..), RegistryEntry (..))
import Seihou.Core.Types
import Seihou.Core.Variable (coerceDefault)
import Seihou.Prelude
import System.FilePath (takeDirectory)
import Prelude hiding (maybe)

-- | Spike: evaluate a Dhall expression containing a record with @name@ and @version@
-- fields. Returns them as a 'Map'.
evalDhallExpr :: Text -> IO (Map Text Text)
evalDhallExpr expr = do
  (name, version) <- input simpleRecordDecoder expr
  pure (Map.fromList [("name", name), ("version", version)])

simpleRecordDecoder :: Decoder (Text, Text)
simpleRecordDecoder =
  record
    ( (,)
        <$> field "name" strictText
        <*> field "version" strictText
    )

-- | Evaluate a @module.dhall@ file and decode it into a 'Module' value.
-- Returns 'Left' with a 'ModuleLoadError' if evaluation or decoding fails.
--
-- Uses 'inputExprWithSettings' to parse, resolve imports, and normalize the
-- Dhall expression, then extracts with 'moduleDecoder' directly. This
-- bypasses Dhall's type-annotation check, allowing the @dependencies@ field
-- to be either @List Text@ (bare strings) or
-- @List { module : Text, vars : ... }@ (parameterized form) — the custom
-- 'dependencyDecoder' handles both at the AST level.
--
-- Note: Dhall 'Decoder' only supports 'Functor', so decoder helpers like
-- 'parseVarType' use 'error' for invalid input. These 'error' calls produce
-- lazy thunks inside the decoded 'Module'. We force evaluation of all
-- potentially-failing fields inside the 'try' block so that exceptions
-- are caught here rather than propagating as uncaught crashes later.
evalModuleFromFile :: FilePath -> IO (Either ModuleLoadError Module)
evalModuleFromFile path = do
  result <- try $ do
    text <- TIO.readFile path
    let settings =
          set rootDirectory (takeDirectory path) $
            set sourceName path defaultInputSettings
    expr <- inputExprWithSettings settings text
    case extract moduleDecoder expr of
      Success m -> do
        -- Force lazy decoder thunks that may contain 'error' calls
        mapM_ (\v -> evaluate v.type_) m.vars
        mapM_ (\s -> evaluate s.strategy >> evaluate s.condition >> mapM_ evaluate s.patch) m.steps
        mapM_ (\c -> mapM_ evaluate c.condition) m.commands
        mapM_ (\p -> evaluate p.condition) m.prompts
        pure m
      Failure e -> throwIO e
  case result of
    Left (e :: SomeException) ->
      let name = guessModuleName path
       in pure $ Left (DhallEvalError name (T.pack (show e)))
    Right m -> pure (Right m)

-- | Evaluate a @recipe.dhall@ file and decode it into a 'Recipe' value.
-- Returns 'Left' with a 'ModuleLoadError' if evaluation or decoding fails.
--
-- Follows the same pattern as 'evalModuleFromFile': uses
-- 'inputExprWithSettings' to parse and normalize, then extracts with
-- 'recipeDecoder'. The recipe's @modules@ field reuses 'dependencyDecoder'.
evalRecipeFromFile :: FilePath -> IO (Either ModuleLoadError Recipe)
evalRecipeFromFile path = do
  result <- try $ do
    text <- TIO.readFile path
    let settings =
          set rootDirectory (takeDirectory path) $
            set sourceName path defaultInputSettings
    expr <- inputExprWithSettings settings text
    case extract recipeDecoder expr of
      Success r -> do
        -- Force lazy decoder thunks that may contain 'error' calls
        mapM_ (\v -> evaluate v.type_) r.vars
        mapM_ (\p -> evaluate p.condition) r.prompts
        pure r
      Failure e -> throwIO e
  case result of
    Left (e :: SomeException) ->
      let name = guessModuleName path
       in pure $ Left (DhallEvalError name (T.pack (show e)))
    Right r -> pure (Right r)

-- | Guess a module name from its file path by taking the parent directory name.
guessModuleName :: FilePath -> ModuleName
guessModuleName path =
  let parts = T.splitOn "/" (T.pack path)
   in case parts of
        [] -> ModuleName "<unknown>"
        [_] -> ModuleName "<unknown>"
        _ ->
          let parentDir = parts !! (length parts - 2)
           in ModuleName parentDir

-- | Wrap a decoder to inject default values for missing record fields.
-- For each @(key, defaultExpr)@, if the key is absent from a 'RecordLit',
-- it is added with the given Dhall expression before extraction.
-- This allows decoders to handle schema evolution gracefully.
withDefaults :: [(Text, Dhall.Expr Src Void)] -> Decoder a -> Decoder a
withDefaults defaults (Decoder ext exp_) = Decoder ext' exp_
  where
    ext' expr@(Dhall.RecordLit fields) =
      let fields' = foldl' addDefault fields defaults
       in ext (Dhall.RecordLit fields')
    ext' expr = ext expr

    addDefault fs (k, v) =
      case DhallMap.lookup k fs of
        Just _ -> fs
        Nothing -> DhallMap.insert k (makeRecordField v) fs

-- | A Dhall expression representing @None Text@.
-- Used as a placeholder default for missing @Optional@-typed fields.
noneText :: Dhall.Expr Src Void
noneText = Dhall.App Dhall.None Dhall.Text

-- | Decoder for the top-level Module type from Dhall.
-- Uses 'withDefaults' to handle modules that predate the @removal@ and
-- @migrations@ fields.
moduleDecoder :: Decoder Module
moduleDecoder =
  withDefaults
    [ ("removal", noneText),
      ("migrations", emptyMigrationList)
    ]
    $ record
      ( Module
          <$> field "name" moduleNameDecoder
          <*> field "version" (maybe strictText)
          <*> field "description" (maybe strictText)
          <*> field "vars" (list varDeclDecoder)
          <*> field "exports" (list varExportDecoder)
          <*> field "prompts" (list promptDecoder)
          <*> field "steps" (list stepDecoder)
          <*> field "commands" (list commandDecoder)
          <*> field "dependencies" (list dependencyDecoder)
          <*> field "removal" (maybe removalDecoder)
          <*> field "migrations" (list migrationDecoder)
      )

-- | A Dhall expression representing an empty list of Migration records.
-- The list element type annotation is unused by the list extractor (which
-- ignores the annotation and reads element values), so we use a placeholder
-- type to keep the synthesized expression compact.
emptyMigrationList :: Dhall.Expr Src Void
emptyMigrationList = Dhall.ListLit (Just Dhall.Text) mempty

-- | Decoder for a single 'Migration' record.
migrationDecoder :: Decoder Migration
migrationDecoder =
  record
    ( Migration
        <$> field "from" strictText
        <*> field "to" strictText
        <*> field "ops" (list migrationOpDecoder)
    )

-- | Decoder for a 'MigrationOp' from a Dhall union value.
-- The union variants must match @schema/MigrationOp.dhall@.
migrationOpDecoder :: Decoder MigrationOp
migrationOpDecoder =
  union
    ( (mkMoveFile <$> constructor "MoveFile" srcDestRecord)
        <> (mkMoveDir <$> constructor "MoveDir" srcDestRecord)
        <> (mkDeleteFile <$> constructor "DeleteFile" pathRecord)
        <> (mkDeleteDir <$> constructor "DeleteDir" pathRecord)
        <> (mkRunCommand <$> constructor "RunCommand" runCommandRecord)
    )
  where
    srcDestRecord :: Decoder (FilePath, FilePath)
    srcDestRecord =
      record ((,) <$> field "src" string <*> field "dest" string)

    pathRecord :: Decoder FilePath
    pathRecord = record (field "path" string)

    runCommandRecord :: Decoder (Text, Maybe FilePath)
    runCommandRecord =
      record ((,) <$> field "run" strictText <*> field "workDir" (maybe string))

    mkMoveFile (s, d) = MoveFile {src = s, dest = d}
    mkMoveDir (s, d) = MoveDir {src = s, dest = d}
    mkDeleteFile p = DeleteFile {path = p}
    mkDeleteDir p = DeleteDir {path = p}
    mkRunCommand (r, wd) = RunCommand {run = r, workDir = wd}

-- | Decoder for the top-level Recipe type from Dhall.
recipeDecoder :: Decoder Recipe
recipeDecoder =
  record
    ( Recipe
        <$> field "name" recipeNameDecoder
        <*> field "version" (maybe strictText)
        <*> field "description" (maybe strictText)
        <*> field "modules" (list dependencyDecoder)
        <*> field "vars" (list varDeclDecoder)
        <*> field "prompts" (list promptDecoder)
    )

recipeNameDecoder :: Decoder RecipeName
recipeNameDecoder = RecipeName <$> strictText

-- | Decoder for a 'BlueprintFile' record. The @src@ is a path relative
-- to the blueprint's @files/@ directory; the optional @description@ is
-- shown to the agent so it can pick the right reference.
blueprintFileDecoder :: Decoder BlueprintFile
blueprintFileDecoder =
  record
    ( BlueprintFile
        <$> field "src" string
        <*> field "description" (maybe strictText)
    )

-- | Decoder for the top-level Blueprint type from Dhall.
blueprintDecoder :: Decoder Blueprint
blueprintDecoder =
  record
    ( Blueprint
        <$> field "name" moduleNameDecoder
        <*> field "version" (maybe strictText)
        <*> field "description" (maybe strictText)
        <*> field "prompt" strictText
        <*> field "vars" (list varDeclDecoder)
        <*> field "prompts" (list promptDecoder)
        <*> field "baseModules" (list dependencyDecoder)
        <*> field "files" (list blueprintFileDecoder)
        <*> field "allowedTools" (maybe (list strictText))
        <*> field "tags" (list strictText)
    )

-- | Evaluate a @blueprint.dhall@ file and decode it into a 'Blueprint'.
-- Returns 'Left' with a 'ModuleLoadError' if evaluation or decoding fails.
--
-- Follows the same pattern as 'evalModuleFromFile' / 'evalRecipeFromFile':
-- uses 'inputExprWithSettings' to parse and normalize, then extracts with
-- 'blueprintDecoder' and forces lazy decoder thunks so 'error' calls in
-- the inner decoders are caught here rather than escaping later.
evalBlueprintFromFile :: FilePath -> IO (Either ModuleLoadError Blueprint)
evalBlueprintFromFile path = do
  result <- try $ do
    text <- TIO.readFile path
    let settings =
          set rootDirectory (takeDirectory path) $
            set sourceName path defaultInputSettings
    expr <- inputExprWithSettings settings text
    case extract blueprintDecoder expr of
      Success b -> do
        mapM_ (\v -> evaluate v.type_) b.vars
        mapM_ (\p -> evaluate p.condition) b.prompts
        pure b
      Failure e -> throwIO e
  case result of
    Left (e :: SomeException) ->
      let nm = guessModuleName path
       in pure $ Left (DhallEvalError nm (T.pack (show e)))
    Right b -> pure (Right b)

-- | Decoder for a 'CommandVar' record.
commandVarDecoder :: Decoder CommandVar
commandVarDecoder =
  record
    ( mkCommandVar
        <$> field "name" varNameDecoder
        <*> field "run" strictText
        <*> field "workDir" (maybe strictText)
        <*> field "when" (maybe strictText)
        <*> field "trim" bool
        <*> field "maxBytes" (maybe natural)
    )
  where
    mkCommandVar name run workDir whenText trim maxBytes =
      CommandVar
        { name = name,
          run = run,
          workDir = workDir,
          condition = parseWhen whenText,
          trim = trim,
          maxBytes = maxBytes
        }

-- | Decoder for optional agent prompt launch metadata.
agentPromptLaunchDecoder :: Decoder AgentPromptLaunch
agentPromptLaunchDecoder =
  record
    ( AgentPromptLaunch
        <$> field "provider" (maybe strictText)
        <*> field "mode" (maybe strictText)
        <*> field "model" (maybe strictText)
    )

-- | Decoder for a prompt guidance block.
promptGuidanceDecoder :: Decoder PromptGuidance
promptGuidanceDecoder =
  record
    ( mkPromptGuidance
        <$> field "title" strictText
        <*> field "body" strictText
        <*> field "when" (maybe strictText)
    )
  where
    mkPromptGuidance title body whenText =
      PromptGuidance
        { title = title,
          body = body,
          condition = parseWhen whenText
        }

-- | Decoder for the top-level AgentPrompt type from Dhall.
agentPromptDecoder :: Decoder AgentPrompt
agentPromptDecoder =
  withDefaults [("guidance", emptyPromptGuidanceList)] $
    record
      ( AgentPrompt
          <$> field "name" moduleNameDecoder
          <*> field "version" (maybe strictText)
          <*> field "description" (maybe strictText)
          <*> field "prompt" strictText
          <*> field "vars" (list varDeclDecoder)
          <*> field "prompts" (list promptDecoder)
          <*> field "commandVars" (list commandVarDecoder)
          <*> field "guidance" (list promptGuidanceDecoder)
          <*> field "files" (list blueprintFileDecoder)
          <*> field "allowedTools" (maybe (list strictText))
          <*> field "tags" (list strictText)
          <*> field "launch" (maybe agentPromptLaunchDecoder)
      )

emptyPromptGuidanceList :: Dhall.Expr Src Void
emptyPromptGuidanceList = Dhall.ListLit (Just Dhall.Text) mempty

-- | Evaluate a @prompt.dhall@ file and decode it into an 'AgentPrompt'.
evalAgentPromptFromFile :: FilePath -> IO (Either ModuleLoadError AgentPrompt)
evalAgentPromptFromFile path = do
  result <- try $ do
    text <- TIO.readFile path
    let settings =
          set rootDirectory (takeDirectory path) $
            set sourceName path defaultInputSettings
    expr <- inputExprWithSettings settings text
    case extract agentPromptDecoder expr of
      Success p -> do
        mapM_ (\v -> evaluate v.type_) p.vars
        mapM_ (\prompt -> evaluate prompt.condition) p.prompts
        mapM_ (\cv -> evaluate cv.condition) p.commandVars
        mapM_ (\g -> evaluate g.condition) p.guidance
        pure p
      Failure e -> throwIO e
  case result of
    Left (e :: SomeException) ->
      let nm = guessModuleName path
       in pure $ Left (DhallEvalError nm (T.pack (show e)))
    Right p -> pure (Right p)

-- | Decoder for Removal from a Dhall record.
removalDecoder :: Decoder Removal
removalDecoder =
  record
    ( Removal
        <$> field "steps" (list removalStepDecoder)
        <*> field "commands" (list commandDecoder)
    )

-- | Decoder for RemovalStep from a Dhall record.
removalStepDecoder :: Decoder RemovalStep
removalStepDecoder =
  record
    ( RemovalStep
        <$> field "action" removalActionDecoder
        <*> field "dest" strictText
        <*> field "src" (maybe string)
    )

-- | Decoder for RemovalAction from a Dhall Text string.
--
-- See 'varTypeDecoder' note re: 'error' safety.
removalActionDecoder :: Decoder RemovalAction
removalActionDecoder = parseRemovalAction <$> strictText
  where
    parseRemovalAction :: Text -> RemovalAction
    parseRemovalAction t = case t of
      "remove-file" -> RemoveFileAction
      "remove-section" -> RemoveSectionAction
      "rewrite-file" -> RewriteFileAction
      -- Caught by 'try' in 'evalModuleFromFile'
      other -> error ("Unknown removal action \"" <> T.unpack other <> "\"; expected one of: remove-file, remove-section, rewrite-file")

moduleNameDecoder :: Decoder ModuleName
moduleNameDecoder = ModuleName <$> strictText

-- | Decoder for a dependency entry.
-- Accepts two Dhall forms for backward compatibility:
--
-- 1. A bare text string: @"base"@ decodes as @Dependency "base" mempty@.
-- 2. A record: @{ module = "base", vars = [ { name = "x", value = "y" } ] }@
--    decodes as @Dependency "base" (fromList [("x", "y")])@.
--
-- This is implemented as a custom decoder that pattern-matches on the Dhall
-- expression AST: 'TextLit' for bare strings, falling back to the record
-- decoder for the parameterized form.
dependencyDecoder :: Decoder Dependency
dependencyDecoder = Decoder extractDep expectedDep
  where
    extractDep :: Dhall.Expr Src Void -> Extractor Src Void Dependency
    extractDep (Dhall.TextLit (Chunks [] t)) =
      pure (simpleDep (ModuleName t))
    extractDep expr =
      extract paramDepDecoder expr

    expectedDep = expected strictText

    paramDepDecoder :: Decoder Dependency
    paramDepDecoder =
      record
        ( mkDep
            <$> field "module" moduleNameDecoder
            <*> field "vars" (list varBindingDecoder)
        )

    varBindingDecoder :: Decoder (VarName, Text)
    varBindingDecoder =
      record
        ( (,)
            <$> field "name" varNameDecoder
            <*> field "value" strictText
        )

    mkDep :: ModuleName -> [(VarName, Text)] -> Dependency
    mkDep name bindings = Dependency {depModule = name, depVars = Map.fromList bindings}

-- | Decoder for VarType from a Dhall Text string.
-- Dhall does not support recursive types, so VarType is represented as a
-- string: @"text"@, @"bool"@, @"int"@, @"list text"@, @"list bool"@,
-- @"list int"@, @"choice"@.
--
-- Note: 'Decoder' only has a 'Functor' instance (no 'Monad'/'MonadFail'), so
-- we cannot use @fail@ for error reporting. The 'error' call throws an
-- 'ErrorCall' exception that is caught by @try@ in 'evalModuleFromFile' and
-- wrapped as @Left (DhallEvalError ...)@.
varTypeDecoder :: Decoder VarType
varTypeDecoder = parseVarType <$> strictText
  where
    parseVarType :: Text -> VarType
    parseVarType t = case T.toLower t of
      "text" -> VTText
      "bool" -> VTBool
      "int" -> VTInt
      "choice" -> VTChoice []
      other
        | "list " `T.isPrefixOf` other ->
            VTList (parseVarType (T.drop 5 other))
        | otherwise ->
            -- Caught by 'try' in 'evalModuleFromFile'
            error ("Unknown var type \"" <> T.unpack other <> "\"; expected one of: text, bool, int, choice, list <type>")

-- | Decoder for Strategy from a Dhall Text string.
--
-- See 'varTypeDecoder' note re: 'error' safety.
strategyDecoder :: Decoder Strategy
strategyDecoder = parseStrategy <$> strictText
  where
    parseStrategy :: Text -> Strategy
    parseStrategy t = case t of
      "copy" -> Copy
      "template" -> Template
      "dhall-text" -> DhallText
      "structured" -> Structured
      -- Caught by 'try' in 'evalModuleFromFile'
      other -> error ("Unknown strategy \"" <> T.unpack other <> "\"; expected one of: copy, template, dhall-text, structured")

-- | Decoder for VarDecl from a Dhall record.
--
-- The @default@ field is decoded as raw text and then coerced against the
-- declared @type@ via 'coerceDeclDefault', so a defaulted variable carries the
-- correctly-typed 'VarValue' for every downstream consumer (e.g. a @bool@
-- default of @"true"@ becomes 'VBool' 'True'). A default that cannot be coerced
-- is an authoring error and fails module load (caught by 'try' in
-- 'evalModuleFromFile'; see the 'varTypeDecoder' note re: 'error' safety).
varDeclDecoder :: Decoder VarDecl
varDeclDecoder =
  fmap coerceDeclDefault $
    record
      ( VarDecl
          <$> field "name" varNameDecoder
          <*> field "type" varTypeDecoder
          <*> field "default" (fmap (fmap VText) (maybe strictText))
          <*> field "description" (maybe strictText)
          <*> field "required" bool
          <*> field "validation" (fmap (fmap ValPattern) (maybe strictText))
      )

-- | Coerce a decoded variable declaration's raw-text default against its
-- declared type. On a type mismatch we 'error' with a clear, load-time message
-- that names the variable and the offending value; this is caught by 'try' in
-- 'evalModuleFromFile' and surfaced as a 'DhallEvalError'.
coerceDeclDefault :: VarDecl -> VarDecl
coerceDeclDefault decl =
  case decl.default_ of
    Nothing -> decl
    Just rawDefault ->
      case coerceDefault decl.name decl.type_ rawDefault of
        Right val -> decl {default_ = Just val}
        -- Caught by 'try' in 'evalModuleFromFile'
        Left err -> error (T.unpack (renderDefaultError decl.name err))

-- | Render a coercion failure for a module default into a load-time message.
renderDefaultError :: VarName -> VarError -> Text
renderDefaultError name (CoercionFailed _ ty raw) =
  "Invalid default for variable '"
    <> name.unVarName
    <> "': cannot coerce "
    <> T.pack (show raw)
    <> " to declared type "
    <> renderVarType ty
renderDefaultError name err =
  "Invalid default for variable '" <> name.unVarName <> "': " <> T.pack (show err)

-- | A short rendering of a declared variable type for error messages.
renderVarType :: VarType -> Text
renderVarType VTText = "text"
renderVarType VTBool = "bool"
renderVarType VTInt = "int"
renderVarType (VTList ty) = "list " <> renderVarType ty
renderVarType (VTChoice _) = "choice"

varNameDecoder :: Decoder VarName
varNameDecoder = VarName <$> strictText

-- | Decoder for VarExport from a Dhall record.
-- Note: the Dhall field is @alias@ rather than @as@ because @as@ is a
-- reserved keyword in Dhall.
varExportDecoder :: Decoder VarExport
varExportDecoder =
  record
    ( VarExport
        <$> field "var" varNameDecoder
        <*> field "alias" (fmap (fmap VarName) (maybe strictText))
    )

-- | Decoder for Prompt from a Dhall record.
-- The @when@ field is parsed via 'parseExpr' into an 'Expr' AST.
-- Parse failures are treated as fatal (via 'error') since they indicate a
-- malformed module definition.
promptDecoder :: Decoder Prompt
promptDecoder =
  record
    ( mkPrompt
        <$> field "var" varNameDecoder
        <*> field "text" strictText
        <*> field "when" (maybe strictText)
        <*> field "choices" (maybe (list strictText))
    )
  where
    mkPrompt v t whenText choices =
      Prompt
        { var = v,
          text = t,
          condition = parseWhen whenText,
          choices = choices
        }

-- | Decoder for PatchOp from a Dhall Text string.
--
-- See 'varTypeDecoder' note re: 'error' safety.
patchOpDecoder :: Decoder PatchOp
patchOpDecoder = parsePatchOp <$> strictText
  where
    parsePatchOp :: Text -> PatchOp
    parsePatchOp t = case t of
      "append-file" -> AppendFile
      "prepend-file" -> PrependFile
      "append-section" -> AppendSection
      "append-line-if-absent" -> AppendLineIfAbsent
      -- Caught by 'try' in 'evalModuleFromFile'
      other -> error ("Unknown patch operation \"" <> T.unpack other <> "\"; expected one of: append-file, prepend-file, append-section, append-line-if-absent")

-- | Decoder for Step from a Dhall record.
-- The @when@ field is parsed via 'parseExpr' into an 'Expr' AST.
-- The @patch@ field is an optional patch operation string.
stepDecoder :: Decoder Step
stepDecoder =
  record
    ( mkStep
        <$> field "strategy" strategyDecoder
        <*> field "src" string
        <*> field "dest" strictText
        <*> field "when" (maybe strictText)
        <*> field "patch" (maybe strictText)
    )
  where
    mkStep strat src dest whenText patchText =
      Step
        { strategy = strat,
          src = src,
          dest = dest,
          condition = parseWhen whenText,
          patch = fmap parsePatchOp patchText
        }
    parsePatchOp "append-file" = AppendFile
    parsePatchOp "prepend-file" = PrependFile
    parsePatchOp "append-section" = AppendSection
    parsePatchOp "append-line-if-absent" = AppendLineIfAbsent
    parsePatchOp other = error ("Unknown patch operation \"" <> T.unpack other <> "\"; expected one of: append-file, prepend-file, append-section, append-line-if-absent")

-- | Decoder for Command from a Dhall record.
-- The @when@ field is parsed via 'parseExpr' into an 'Expr' AST.
commandDecoder :: Decoder Command
commandDecoder =
  record
    ( mkCommand
        <$> field "run" strictText
        <*> field "workDir" (maybe strictText)
        <*> field "when" (maybe strictText)
    )
  where
    mkCommand run workDir whenText =
      Command
        { run = run,
          workDir = workDir,
          condition = parseWhen whenText
        }

-- | Parse an optional @when@ expression text into an 'Expr'.
-- Returns 'Nothing' for 'Nothing' input, 'Just expr' on success, or calls
-- 'error' on a malformed expression. The 'error' is caught by @try@ in
-- 'evalModuleFromFile' (see 'varTypeDecoder' note).
parseWhen :: Maybe Text -> Maybe Expr
parseWhen Nothing = Nothing
parseWhen (Just t) = case parseExpr t of
  Right expr -> Just expr
  -- Caught by 'try' in 'evalModuleFromFile'
  Left err -> error ("Invalid when expression \"" <> T.unpack t <> "\": " <> T.unpack err)

-- | Decoder for a single registry entry from a Dhall record.
-- Uses 'withDefaults' to handle registries that omit the @version@ field.
registryEntryDecoder :: Decoder RegistryEntry
registryEntryDecoder =
  withDefaults [("version", noneText)] $
    record
      ( RegistryEntry
          <$> field "name" moduleNameDecoder
          <*> field "version" (maybe strictText)
          <*> field "path" string
          <*> field "description" (maybe strictText)
          <*> field "tags" (list strictText)
      )

-- | Decoder for a registry metadata file from Dhall.
-- Uses 'withDefaults' to handle registries that omit the @recipes@ or
-- @blueprints@ or @prompts@ field (backwards compatibility with existing
-- seihou-registry.dhall files).
registryDecoder :: Decoder Registry
registryDecoder =
  withDefaults
    [ ("recipes", emptyRegistryEntryList),
      ("blueprints", emptyRegistryEntryList),
      ("prompts", emptyRegistryEntryList)
    ]
    $ record
      ( Registry
          <$> field "repoName" strictText
          <*> field "repoDescription" (maybe strictText)
          <*> field "modules" (list registryEntryDecoder)
          <*> field "recipes" (list registryEntryDecoder)
          <*> field "blueprints" (list registryEntryDecoder)
          <*> field "prompts" (list registryEntryDecoder)
      )

-- | A Dhall expression representing an empty list of registry entries.
-- Used as a default for the @recipes@ field in registries that predate recipe support.
emptyRegistryEntryList :: Dhall.Expr Src Void
emptyRegistryEntryList =
  Dhall.ListLit
    ( Just
        ( Dhall.Record
            ( DhallMap.fromList
                [ ("name", makeRecordField Dhall.Text),
                  ("version", makeRecordField (Dhall.App Dhall.Optional Dhall.Text)),
                  ("path", makeRecordField Dhall.Text),
                  ("description", makeRecordField (Dhall.App Dhall.Optional Dhall.Text)),
                  ("tags", makeRecordField (Dhall.App Dhall.List Dhall.Text))
                ]
            )
        )
    )
    mempty

-- | Evaluate a @seihou-registry.dhall@ file and decode it into a 'Registry'.
-- Returns 'Left' with a 'RegistryEvalError' if evaluation or decoding fails.
--
-- Uses 'inputExprWithSettings' to parse, resolve imports, and normalize the
-- Dhall expression, then extracts with 'registryDecoder' directly. This
-- bypasses Dhall's type-annotation check, allowing registry entries to omit
-- optional fields like @version@ — the custom 'registryEntryDecoder' handles
-- missing fields at the AST level.
evalRegistryFromFile :: FilePath -> IO (Either ModuleLoadError Registry)
evalRegistryFromFile path = do
  result <- try $ do
    text <- TIO.readFile path
    let settings =
          set rootDirectory (takeDirectory path) $
            set sourceName path defaultInputSettings
    expr <- inputExprWithSettings settings text
    case extract registryDecoder expr of
      Success r -> pure r
      Failure e -> throwIO e
  case result of
    Left (e :: SomeException) ->
      pure $ Left (RegistryEvalError (T.pack path) (T.pack (show e)))
    Right r -> pure (Right r)