packages feed

seihou-core-0.16.0.0: src/Seihou/Core/Module.hs

module Seihou.Core.Module
  ( discoverModule,
    discoverBlueprint,
    discoverAgentPrompt,
    discoverRunnable,
    defaultSearchPaths,
    validateModule,
    loadModule,
    discoverAllModules,
    discoverAllRunnables,
    DiscoveredModule (..),
    DiscoveredRunnable (..),
    RunnableKind (..),
    ModuleSource (..),

    -- * Individual check functions (for structured reports)
    checkNameFormat,
    checkVersionPresent,
    checkUniqueVars,
    checkPromptRefs,
    checkFileExistence,
    checkExportRefs,
    checkDependencyNames,
    checkDependencyVarBindings,
    checkSafeDestinations,
    checkModuleStateDestinations,
    checkDestVarRefs,
    checkCommandSafety,
    checkSeedSteps,
    checkCheckoutSteps,
    isValidModuleName,
    extractPlaceholders,
    validateProjectRelativePath,
  )
where

import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Maybe (isJust, maybeToList)
import Data.Set qualified as Set
import Data.Text qualified as T
import Seihou.Core.ArtifactFiles (judgementAmbiguity)
import Seihou.Core.Checkout (CheckoutError (..), validateCheckoutPaths)
import Seihou.Core.Path (PathOperand (..), controlPathReason, ownStateDirectoryViolation, validateProjectRelativePath)
import Seihou.Core.Types
import Seihou.Dhall.Eval (evalAgentPromptFromFile, evalBlueprintFromFile, evalJudgementFromFile, evalModuleFromFile, evalRecipeFromFile)
import Seihou.Prelude
import System.Directory (XdgDirectory (..), doesDirectoryExist, doesFileExist, getCurrentDirectory, getXdgDirectory, listDirectory)

-- | Search for a module by name in the given directories.
-- Returns the path to the directory containing @module.dhall@, or
-- 'ModuleNotFound' listing the directories that were searched.
discoverModule :: [FilePath] -> ModuleName -> IO (Either ModuleLoadError FilePath)
discoverModule searchPaths name = go searchPaths
  where
    nameStr = T.unpack (name ^. #unModuleName)
    go [] = pure $ Left (ModuleNotFound name searchPaths)
    go (dir : rest) = do
      let candidate = dir </> nameStr
      let dhallFile = candidate </> "module.dhall"
      exists <- doesFileExist dhallFile
      if exists
        then pure (Right candidate)
        else go rest

-- | Search for a runnable (module, recipe, blueprint, or prompt) by name in the
-- given directories. For each search path, checks @module.dhall@ first
-- (returning 'RunnableModule'), then @recipe.dhall@
-- (returning 'RunnableRecipe'), then @blueprint.dhall@
-- (returning 'RunnableBlueprint'), then @prompt.dhall@
-- (returning 'RunnableAgentPrompt'). Within a single candidate directory
-- the priority is module > recipe > blueprint > prompt, so a stray
-- @module.dhall@ next to a @blueprint.dhall@ silently surfaces the
-- module — the more specific, deterministic artifact. A @judgement.dhall@
-- (returning 'RunnableJudgement') does not join that precedence: next to any
-- other definition file it is 'AmbiguousArtifact'. Returns
-- 'ModuleNotFound' if none is found in any search path.
discoverRunnable :: [FilePath] -> ModuleName -> IO (Either ModuleLoadError Runnable)
discoverRunnable searchPaths name = go searchPaths
  where
    nameStr = T.unpack (name ^. #unModuleName)
    go [] = pure $ Left (ModuleNotFound name searchPaths)
    go (dir : rest) = do
      let candidate = dir </> nameStr
      ambiguity <- judgementAmbiguity candidate
      case ambiguity of
        Just files -> pure (Left (AmbiguousArtifact name candidate files))
        Nothing -> probe candidate rest
    probe candidate rest = do
      let moduleDhall = candidate </> "module.dhall"
          recipeDhall = candidate </> "recipe.dhall"
          blueprintDhall = candidate </> "blueprint.dhall"
          promptDhall = candidate </> "prompt.dhall"
      isModule <- doesFileExist moduleDhall
      if isModule
        then do
          result <- evalModuleFromFile moduleDhall
          case result of
            Left err -> pure (Left err)
            Right m -> pure (Right (RunnableModule m candidate))
        else do
          isRecipe <- doesFileExist recipeDhall
          if isRecipe
            then do
              result <- evalRecipeFromFile recipeDhall
              case result of
                Left err -> pure (Left err)
                Right r -> pure (Right (RunnableRecipe r candidate))
            else do
              isBlueprint <- doesFileExist blueprintDhall
              if isBlueprint
                then do
                  result <- evalBlueprintFromFile blueprintDhall
                  case result of
                    Left err -> pure (Left err)
                    Right b -> pure (Right (RunnableBlueprint b candidate))
                else do
                  isPrompt <- doesFileExist promptDhall
                  if isPrompt
                    then do
                      result <- evalAgentPromptFromFile promptDhall
                      case result of
                        Left err -> pure (Left err)
                        Right p -> pure (Right (RunnableAgentPrompt p candidate))
                    else do
                      let judgementDhall = candidate </> "judgement.dhall"
                      isJudgement <- doesFileExist judgementDhall
                      if isJudgement
                        then do
                          result <- evalJudgementFromFile judgementDhall
                          pure (fmap (`RunnableJudgement` candidate) result)
                        else go rest

-- | Search for a blueprint by name in the given directories. Returns
-- the path to the directory containing @blueprint.dhall@, or
-- 'ModuleNotFound' listing the directories that were searched. Mirrors
-- 'discoverModule' for callers that only care about discovering a
-- blueprint by name.
discoverBlueprint :: [FilePath] -> ModuleName -> IO (Either ModuleLoadError FilePath)
discoverBlueprint searchPaths name = go searchPaths
  where
    nameStr = T.unpack (name ^. #unModuleName)
    go [] = pure $ Left (ModuleNotFound name searchPaths)
    go (dir : rest) = do
      let candidate = dir </> nameStr
      let dhallFile = candidate </> "blueprint.dhall"
      exists <- doesFileExist dhallFile
      if exists
        then pure (Right candidate)
        else go rest

-- | Search for an agent prompt by name in the given directories. Returns
-- the path to the directory containing @prompt.dhall@, or 'ModuleNotFound'
-- listing the directories that were searched.
discoverAgentPrompt :: [FilePath] -> ModuleName -> IO (Either ModuleLoadError FilePath)
discoverAgentPrompt searchPaths name = go searchPaths
  where
    nameStr = T.unpack (name ^. #unModuleName)
    go [] = pure $ Left (ModuleNotFound name searchPaths)
    go (dir : rest) = do
      let candidate = dir </> nameStr
      let dhallFile = candidate </> "prompt.dhall"
      exists <- doesFileExist dhallFile
      if exists
        then pure (Right candidate)
        else go rest

-- | The three standard module search paths, in priority order:
-- 1. @.seihou/modules/@ relative to the current directory
-- 2. @~/.config/seihou/modules/@
-- 3. @~/.config/seihou/installed/@
defaultSearchPaths :: IO [FilePath]
defaultSearchPaths = do
  cwd <- getCurrentDirectory
  xdgConfig <- getXdgDirectory XdgConfig "seihou"
  pure
    [ cwd </> ".seihou" </> "modules",
      xdgConfig </> "modules",
      xdgConfig </> "installed"
    ]

-- | Validate a decoded 'Module' against the nine validation rules.
-- The 'FilePath' is the module's base directory (containing @module.dhall@).
-- Returns 'Right' with the module if all rules pass, or 'Left' with a
-- 'ValidationError' listing all violations.
validateModule :: FilePath -> Module -> IO (Either ModuleLoadError Module)
validateModule baseDir m = do
  fileErrors <- checkFileExistence baseDir m
  let pureErrors =
        checkNameFormat m
          <> checkVersionPresent m
          <> checkUniqueVars m
          <> checkPromptRefs m
          <> checkExportRefs m
          <> checkDependencyNames m
          <> checkDependencyVarBindings m
          <> checkSafeDestinations m
          <> checkDestVarRefs m
          <> checkCommandSafety m
          <> checkSeedSteps m
          <> checkCheckoutSteps m
      allErrors = pureErrors <> fileErrors
  pure $
    if null allErrors
      then Right m
      else Left (ValidationError (m ^. #name) allErrors)

-- Rule 1: Module name must be non-empty and match [a-z][a-z0-9-]*
checkNameFormat :: Module -> [Text]
checkNameFormat m =
  let n = (m ^. #name . #unModuleName)
   in if T.null n || not (isValidModuleName n)
        then ["module name must match [a-z][a-z0-9-]*, got: " <> n]
        else []

isValidModuleName :: Text -> Bool
isValidModuleName t = case T.uncons t of
  Nothing -> False
  Just (c, rest) ->
    (c >= 'a' && c <= 'z')
      && T.all (\ch -> (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-') rest

-- Rule 1b: Module must declare a version
checkVersionPresent :: Module -> [Text]
checkVersionPresent m = case m ^. #version of
  Nothing -> ["module must declare a version"]
  Just v
    | T.null (T.strip v) -> ["module must declare a version"]
    | otherwise -> []

-- Rule 2: All variable names must be unique
checkUniqueVars :: Module -> [Text]
checkUniqueVars m =
  let names = map (\d -> d ^. #name . #unVarName) (m ^. #vars)
   in map (\n -> "duplicate variable name: " <> n) (findDupes Set.empty Set.empty names)

findDupes :: Set.Set Text -> Set.Set Text -> [Text] -> [Text]
findDupes _ _ [] = []
findDupes seen reported (x : xs)
  | Set.member x seen && not (Set.member x reported) = x : findDupes seen (Set.insert x reported) xs
  | otherwise = findDupes (Set.insert x seen) reported xs

-- Rule 3: Every prompt must reference a declared variable
checkPromptRefs :: Module -> [Text]
checkPromptRefs m =
  let varNames = Set.fromList (map (^. #name) (m ^. #vars))
   in concatMap
        ( \p ->
            if Set.member (p ^. #var) varNames
              then []
              else ["prompt references undeclared variable: " <> p ^. #var . #unVarName]
        )
        (m ^. #prompts)

-- Rule 4: Every step source file must exist in the module's files/ directory
checkFileExistence :: FilePath -> Module -> IO [Text]
checkFileExistence baseDir m =
  concat
    <$> mapM
      ( \s ->
          if s ^. #strategy == Symlink
            then pure []
            else do
              let p = baseDir </> "files" </> (s ^. #src)
              exists <- doesFileExist p
              pure $
                if exists
                  then []
                  else ["step source file not found: " <> T.pack (s ^. #src)]
      )
      (m ^. #steps)

-- Rule 5: Every export must reference a declared variable
checkExportRefs :: Module -> [Text]
checkExportRefs m =
  let varNames = Set.fromList (map (^. #name) (m ^. #vars))
   in concatMap
        ( \e ->
            if Set.member (e ^. #var) varNames
              then []
              else ["export references undeclared variable: " <> e ^. #var . #unVarName]
        )
        (m ^. #exports)

-- Rule 6: Every dependency name must be well-formed
checkDependencyNames :: Module -> [Text]
checkDependencyNames m =
  concatMap
    ( \dep ->
        let n = (dep ^. #module_ . #unModuleName)
         in if isValidModuleName n
              then []
              else ["invalid dependency name: " <> n]
    )
    (m ^. #dependencies)

-- Rule 6b: Dependency var binding names must be non-empty
checkDependencyVarBindings :: Module -> [Text]
checkDependencyVarBindings m =
  concatMap
    ( \dep ->
        concatMap
          ( \(VarName vn) ->
              if T.null vn
                then ["dependency '" <> dep ^. #module_ . #unModuleName <> "' has empty var binding name"]
                else []
          )
          (Map.keys (dep ^. #vars))
    )
    (m ^. #dependencies)

-- Rule 7: Every step destination must be a safe relative path and must not
-- target Seihou's or Git's control data. Only the control rule applies when a
-- module loads; the rule that a module writes under @.seihou/@ only inside its
-- own directory is 'checkModuleStateDestinations', enforced when output is
-- generated, so that existing applications of an older module still load for
-- @seihou remove@ and @seihou status@.
checkSafeDestinations :: Module -> [Text]
checkSafeDestinations m = concatMap checkDest (m ^. #steps)
  where
    checkDest s =
      case validateProjectRelativePath (s ^. #dest) of
        Left err -> ["step destination " <> err]
        Right safePath
          | literalControlPrefix (s ^. #dest),
            Just reason <- controlPathReason FileTarget safePath ->
              ["step destination " <> reason]
          | otherwise -> []

-- | A module may write under @.seihou/@ only inside @.seihou/<its name>/@.
-- Checks each literal step destination; one whose first two segments contain a
-- placeholder is left to plan time. Control paths are reported by
-- 'checkSafeDestinations' and are not repeated here.
checkModuleStateDestinations :: Module -> [Text]
checkModuleStateDestinations m = concatMap checkDest (m ^. #steps)
  where
    checkDest s = case validateProjectRelativePath (s ^. #dest) of
      Right safePath
        | literalControlPrefix (s ^. #dest),
          Nothing <- controlPathReason FileTarget safePath,
          Just violation <- ownStateDirectoryViolation (m ^. #name . #unModuleName) FileTarget safePath ->
            ["step destination " <> violation]
      _ -> []

-- | Whether the segments that decide a path's control class (the first two)
-- are free of placeholders, so the literal destination can be judged now.
literalControlPrefix :: Text -> Bool
literalControlPrefix dest =
  not (any ("{{" `T.isInfixOf`) (take 2 (filter (not . T.null) (T.split (\c -> c == '/' || c == '\\') dest))))

-- Rule 8: Variables referenced in step dest placeholders must be declared
checkDestVarRefs :: Module -> [Text]
checkDestVarRefs m =
  let varNames = Set.fromList (map (\d -> d ^. #name . #unVarName) (m ^. #vars))
   in concatMap (checkStep varNames) (m ^. #steps)
  where
    checkStep varNames s =
      [ "step destination references undeclared variable: " <> ref
      | ref <- extractPlaceholders (s ^. #dest),
        not (Set.member ref varNames)
      ]

-- Rule 9: Command text must be non-empty and workDir must be safe
checkCommandSafety :: Module -> [Text]
checkCommandSafety m = concatMap checkCmd (m ^. #commands) <> ["Removal.commands cannot declare checkout setup" | removal <- maybeToList (m ^. #removal), command <- removal ^. #commands, command ^. #lifecycle == CheckoutCommandLifecycle]
  where
    checkCmd c = checkEmptyRun c <> checkWorkDir (c ^. #workDir)

    checkEmptyRun c
      | T.null (T.strip (c ^. #run)) = ["command text must not be empty"]
      | otherwise = []

    checkWorkDir Nothing = []
    checkWorkDir (Just wd) =
      case validateProjectRelativePath wd of
        Left err -> ["command workDir " <> err]
        Right _ -> []

-- | Extract placeholder variable references from a text like @"src/{{project.name}}/Main.hs"@.
extractPlaceholders :: Text -> [Text]
extractPlaceholders t = case T.breakOn "{{" t of
  (_, rest)
    | T.null rest -> []
    | otherwise ->
        let afterOpen = T.drop 2 rest
         in case T.breakOn "}}" afterOpen of
              (ref, rest')
                | T.null rest' -> []
                | otherwise -> T.strip ref : extractPlaceholders (T.drop 2 rest')

-- | Load a module by name: discover, evaluate, decode, and validate.
loadModule :: [FilePath] -> ModuleName -> IO (Either ModuleLoadError Module)
loadModule searchPaths name = do
  discovered <- discoverModule searchPaths name
  case discovered of
    Left err -> pure (Left err)
    Right moduleDir -> do
      let dhallFile = moduleDir </> "module.dhall"
      decoded <- evalModuleFromFile dhallFile
      case decoded of
        Left err -> pure (Left err)
        Right m -> validateModule moduleDir m

-- | Which search path category a module was found in.
data ModuleSource = SourceProject | SourceUser | SourceInstalled
  deriving stock (Eq, Show, Generic)

-- | A module discovered during enumeration, with its load result and source.
data DiscoveredModule = DiscoveredModule
  { result :: !(Either ModuleLoadError Module),
    source :: !ModuleSource,
    dir :: !FilePath
  }
  deriving stock (Generic, Show)

-- | Enumerate all modules across the given search paths.
-- The search paths must be in the same order as 'defaultSearchPaths':
-- project-local, user, installed. Each subdirectory containing a
-- @module.dhall@ file is loaded; failures are captured in 'discoveredResult'.
discoverAllModules :: [FilePath] -> IO [DiscoveredModule]
discoverAllModules searchPaths = do
  let tagged = zip searchPaths (sources ++ repeat SourceInstalled)
  concat <$> mapM (uncurry scanPath) tagged
  where
    sources = [SourceProject, SourceUser, SourceInstalled]

    scanPath :: FilePath -> ModuleSource -> IO [DiscoveredModule]
    scanPath dir src = do
      exists <- doesDirectoryExist dir
      if not exists
        then pure []
        else do
          entries <- listDirectory dir
          candidates <- filterM (isModuleDir dir) entries
          mapM (loadOne dir src) candidates

    isModuleDir :: FilePath -> FilePath -> IO Bool
    isModuleDir parent entry = do
      let candidate = parent </> entry </> "module.dhall"
      doesFileExist candidate

    filterM :: (a -> IO Bool) -> [a] -> IO [a]
    filterM _ [] = pure []
    filterM p (x : xs) = do
      keep <- p x
      rest <- filterM p xs
      pure (if keep then x : rest else rest)

    loadOne :: FilePath -> ModuleSource -> FilePath -> IO DiscoveredModule
    loadOne dir src entry = do
      let moduleDir = dir </> entry
          dhallFile = moduleDir </> "module.dhall"
      decoded <- evalModuleFromFile dhallFile
      result <- case decoded of
        Left err -> pure (Left err)
        Right m -> validateModule moduleDir m
      pure DiscoveredModule {result = result, source = src, dir = moduleDir}

-- | Whether a discovered item is a module, recipe, blueprint, or prompt.
data RunnableKind = KindModule | KindRecipe | KindBlueprint | KindPrompt | KindJudgement
  deriving stock (Eq, Show, Generic)

-- | A runnable discovered during enumeration, with its load result, kind, and source.
data DiscoveredRunnable = DiscoveredRunnable
  { name :: !Text,
    description :: !(Maybe Text),
    kind :: !RunnableKind,
    source :: !ModuleSource,
    dir :: !FilePath,
    isError :: !Bool,
    error :: !(Maybe Text)
  }
  deriving stock (Generic, Show)

-- | Enumerate all modules, recipes, blueprints, prompts, and judgements across
-- the given search paths. Returns a unified list of discovered items, each
-- tagged with its kind. A directory whose @judgement.dhall@ shares it with
-- another definition file is listed once, as an erroring judgement.
discoverAllRunnables :: [FilePath] -> IO [DiscoveredRunnable]
discoverAllRunnables searchPaths = do
  let tagged = zip searchPaths (sources ++ repeat SourceInstalled)
  concat <$> mapM (uncurry scanRunnablePath) tagged
  where
    sources = [SourceProject, SourceUser, SourceInstalled]

    scanRunnablePath :: FilePath -> ModuleSource -> IO [DiscoveredRunnable]
    scanRunnablePath dir src = do
      exists <- doesDirectoryExist dir
      if not exists
        then pure []
        else do
          entries <- listDirectory dir
          concat <$> mapM (loadRunnable dir src) entries

    loadRunnable :: FilePath -> ModuleSource -> FilePath -> IO [DiscoveredRunnable]
    loadRunnable dir src entry = do
      ambiguity <- judgementAmbiguity (dir </> entry)
      case ambiguity of
        Just files ->
          pure
            [ DiscoveredRunnable
                { name = T.pack entry,
                  description = Nothing,
                  kind = KindJudgement,
                  source = src,
                  dir = dir </> entry,
                  isError = True,
                  error = Just ("ambiguous: " <> T.intercalate ", " (map T.pack files))
                }
            ]
        Nothing -> loadUnambiguous dir src entry

    loadUnambiguous :: FilePath -> ModuleSource -> FilePath -> IO [DiscoveredRunnable]
    loadUnambiguous dir src entry = do
      let entryDir = dir </> entry
          moduleDhall = entryDir </> "module.dhall"
          recipeDhall = entryDir </> "recipe.dhall"
          blueprintDhall = entryDir </> "blueprint.dhall"
          promptDhall = entryDir </> "prompt.dhall"
      isModule <- doesFileExist moduleDhall
      isRecipe <- doesFileExist recipeDhall
      isBlueprint <- doesFileExist blueprintDhall
      isPrompt <- doesFileExist promptDhall
      if isModule
        then do
          decoded <- evalModuleFromFile moduleDhall
          pure
            [ case decoded of
                Left err ->
                  DiscoveredRunnable
                    { name = T.pack entry,
                      description = Nothing,
                      kind = KindModule,
                      source = src,
                      dir = entryDir,
                      isError = True,
                      error = Just (briefLoadError err)
                    }
                Right m ->
                  DiscoveredRunnable
                    { name = m ^. #name . #unModuleName,
                      description = m ^. #description,
                      kind = KindModule,
                      source = src,
                      dir = entryDir,
                      isError = False,
                      error = Nothing
                    }
            ]
        else
          if isRecipe
            then do
              decoded <- evalRecipeFromFile recipeDhall
              pure
                [ case decoded of
                    Left err ->
                      DiscoveredRunnable
                        { name = T.pack entry,
                          description = Nothing,
                          kind = KindRecipe,
                          source = src,
                          dir = entryDir,
                          isError = True,
                          error = Just (briefLoadError err)
                        }
                    Right r ->
                      DiscoveredRunnable
                        { name = r ^. #name . #unRecipeName,
                          description = r ^. #description,
                          kind = KindRecipe,
                          source = src,
                          dir = entryDir,
                          isError = False,
                          error = Nothing
                        }
                ]
            else
              if isBlueprint
                then do
                  decoded <- evalBlueprintFromFile blueprintDhall
                  pure
                    [ case decoded of
                        Left err ->
                          DiscoveredRunnable
                            { name = T.pack entry,
                              description = Nothing,
                              kind = KindBlueprint,
                              source = src,
                              dir = entryDir,
                              isError = True,
                              error = Just (briefLoadError err)
                            }
                        Right b ->
                          DiscoveredRunnable
                            { name = b ^. #name . #unModuleName,
                              description = b ^. #description,
                              kind = KindBlueprint,
                              source = src,
                              dir = entryDir,
                              isError = False,
                              error = Nothing
                            }
                    ]
                else
                  if isPrompt
                    then do
                      decoded <- evalAgentPromptFromFile promptDhall
                      pure
                        [ case decoded of
                            Left err ->
                              DiscoveredRunnable
                                { name = T.pack entry,
                                  description = Nothing,
                                  kind = KindPrompt,
                                  source = src,
                                  dir = entryDir,
                                  isError = True,
                                  error = Just (briefLoadError err)
                                }
                            Right p ->
                              DiscoveredRunnable
                                { name = p ^. #name . #unModuleName,
                                  description = p ^. #description,
                                  kind = KindPrompt,
                                  source = src,
                                  dir = entryDir,
                                  isError = False,
                                  error = Nothing
                                }
                        ]
                    else loadJudgement src entry entryDir

    loadJudgement :: ModuleSource -> FilePath -> FilePath -> IO [DiscoveredRunnable]
    loadJudgement src entry entryDir = do
      let judgementDhall = entryDir </> "judgement.dhall"
      isJudgement <- doesFileExist judgementDhall
      if not isJudgement
        then pure []
        else do
          decoded <- evalJudgementFromFile judgementDhall
          pure
            [ case decoded of
                Left err ->
                  DiscoveredRunnable
                    { name = T.pack entry,
                      description = Nothing,
                      kind = KindJudgement,
                      source = src,
                      dir = entryDir,
                      isError = True,
                      error = Just (briefLoadError err)
                    }
                Right j ->
                  DiscoveredRunnable
                    { name = j ^. #name,
                      description = j ^. #description,
                      kind = KindJudgement,
                      source = src,
                      dir = entryDir,
                      isError = False,
                      error = Nothing
                    }
            ]

    briefLoadError :: ModuleLoadError -> Text
    briefLoadError (DhallEvalError _ _) = "Dhall evaluation failed"
    briefLoadError (DhallDecodeError _ _) = "Dhall decode failed"
    briefLoadError (ValidationError _ _) = "validation failed"
    briefLoadError (ModuleNotFound _ _) = "not found"
    briefLoadError (MissingSourceFile _ _) = "missing source file"
    briefLoadError (CircularDependency _) = "circular dependency"
    briefLoadError (RegistryEvalError _ _) = "registry eval failed"
    briefLoadError (AmbiguousArtifact _ _ _) = "ambiguous artifact"

-- | A seed creates an entire file and must never be subject to automatic removal.
checkSeedSteps :: Module -> [Text]
checkSeedSteps m = concatMap checkStep seedSteps <> mixed <> removals
  where
    seedSteps = filter ((== Seed) . (^. #lifecycle)) (m ^. #steps)
    checkStep s =
      ["step writing '" <> s ^. #dest <> "' is a seed but has a patch; a seed creates a whole file once and cannot patch one" | isJust (s ^. #patch)]
        <> ["step writing '" <> s ^. #dest <> "' is a seed but uses an unsupported strategy; seeds support copy, template, and dhall-text" | s ^. #strategy `notElem` [Copy, Template, DhallText]]
    mixed =
      [ "steps writing '" <> dest <> "' mix the seed and managed lifecycles; use one lifecycle per destination"
      | dest <- Set.toAscList (Set.fromList (map (^. #dest) seedSteps)),
        any (\s -> s ^. #dest == dest && s ^. #lifecycle == Managed) (m ^. #steps)
      ]
    removals =
      [ "removal step deletes '" <> s ^. #dest <> "', which a seed step creates; seihou never deletes seed files"
      | removal <- maybeToList (m ^. #removal),
        r <- removal ^. #steps,
        s <- seedSteps,
        s ^. #dest == r ^. #dest
      ]

-- | Directory links are declarations about the consuming project, not cached files.
checkCheckoutSteps :: Module -> [Text]
checkCheckoutSteps m = concatMap check (m ^. #steps)
  where
    check s
      | s ^. #strategy /= Symlink && s ^. #lifecycle /= Checkout = []
      | otherwise =
          [label s <> "requires strategy symlink and lifecycle checkout together" | s ^. #strategy /= Symlink || s ^. #lifecycle /= Checkout]
            <> [label s <> "cannot carry a patch" | isJust (s ^. #patch)]
            <> [label s <> err ^. #message | Left err <- [validateCheckoutPaths (s ^. #src) (T.unpack (s ^. #dest))]]
            <> [label s <> "source references undeclared variable: " <> name | name <- extractPlaceholders (T.pack (s ^. #src)), name `notElem` map (^. #name . #unVarName) (m ^. #vars)]
            <> [label s <> "has malformed placeholders" | path <- [T.pack (s ^. #src), s ^. #dest], T.count "{{" path /= T.count "}}" path]
    label s = "step writing '" <> s ^. #dest <> "' "