packages feed

seihou-core-0.4.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,
    checkDestVarRefs,
    checkCommandSafety,
    isValidModuleName,
    extractPlaceholders,
    validateProjectRelativePath,
  )
where

import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as T
import GHC.Generics (Generic)
import Seihou.Core.Path (validateProjectRelativePath)
import Seihou.Core.Types
import Seihou.Dhall.Eval (evalAgentPromptFromFile, evalBlueprintFromFile, 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. 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
          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 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
      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 -> 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.depModule.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.depModule.unModuleName <> "' has empty var binding name"]
                else []
          )
          (Map.keys dep.depVars)
    )
    m.dependencies

-- Rule 7: Every step destination must be a safe relative path
checkSafeDestinations :: Module -> [Text]
checkSafeDestinations m = concatMap checkDest m.steps
  where
    checkDest s =
      case validateProjectRelativePath s.dest of
        Left err -> ["step destination " <> err]
        Right _ -> []

-- 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
  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
  { discoveredResult :: Either ModuleLoadError Module,
    discoveredSource :: ModuleSource,
    discoveredDir :: FilePath
  }
  deriving stock (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 {discoveredResult = result, discoveredSource = src, discoveredDir = moduleDir}

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

-- | A runnable discovered during enumeration, with its load result, kind, and source.
data DiscoveredRunnable = DiscoveredRunnable
  { drName :: Text,
    drDescription :: Maybe Text,
    drKind :: RunnableKind,
    drSource :: ModuleSource,
    drDir :: FilePath,
    drIsError :: Bool,
    drError :: Maybe Text
  }
  deriving stock (Show)

-- | Enumerate all modules, recipes, blueprints, and prompts across the given search paths.
-- Returns a unified list of discovered items, each tagged with its kind.
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
      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
                    { drName = T.pack entry,
                      drDescription = Nothing,
                      drKind = KindModule,
                      drSource = src,
                      drDir = entryDir,
                      drIsError = True,
                      drError = Just (briefLoadError err)
                    }
                Right m ->
                  DiscoveredRunnable
                    { drName = m.name.unModuleName,
                      drDescription = m.description,
                      drKind = KindModule,
                      drSource = src,
                      drDir = entryDir,
                      drIsError = False,
                      drError = Nothing
                    }
            ]
        else
          if isRecipe
            then do
              decoded <- evalRecipeFromFile recipeDhall
              pure
                [ case decoded of
                    Left err ->
                      DiscoveredRunnable
                        { drName = T.pack entry,
                          drDescription = Nothing,
                          drKind = KindRecipe,
                          drSource = src,
                          drDir = entryDir,
                          drIsError = True,
                          drError = Just (briefLoadError err)
                        }
                    Right r ->
                      DiscoveredRunnable
                        { drName = r.name.unRecipeName,
                          drDescription = r.description,
                          drKind = KindRecipe,
                          drSource = src,
                          drDir = entryDir,
                          drIsError = False,
                          drError = Nothing
                        }
                ]
            else
              if isBlueprint
                then do
                  decoded <- evalBlueprintFromFile blueprintDhall
                  pure
                    [ case decoded of
                        Left err ->
                          DiscoveredRunnable
                            { drName = T.pack entry,
                              drDescription = Nothing,
                              drKind = KindBlueprint,
                              drSource = src,
                              drDir = entryDir,
                              drIsError = True,
                              drError = Just (briefLoadError err)
                            }
                        Right b ->
                          DiscoveredRunnable
                            { drName = b.name.unModuleName,
                              drDescription = b.description,
                              drKind = KindBlueprint,
                              drSource = src,
                              drDir = entryDir,
                              drIsError = False,
                              drError = Nothing
                            }
                    ]
                else
                  if isPrompt
                    then do
                      decoded <- evalAgentPromptFromFile promptDhall
                      pure
                        [ case decoded of
                            Left err ->
                              DiscoveredRunnable
                                { drName = T.pack entry,
                                  drDescription = Nothing,
                                  drKind = KindPrompt,
                                  drSource = src,
                                  drDir = entryDir,
                                  drIsError = True,
                                  drError = Just (briefLoadError err)
                                }
                            Right p ->
                              DiscoveredRunnable
                                { drName = p.name.unModuleName,
                                  drDescription = p.description,
                                  drKind = KindPrompt,
                                  drSource = src,
                                  drDir = entryDir,
                                  drIsError = False,
                                  drError = Nothing
                                }
                        ]
                    else pure []

    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"