packages feed

seihou-cli-0.16.0.0: src/Seihou/CLI/RemoteVersion.hs

module Seihou.CLI.RemoteVersion
  ( FetchError (..),
    fetchTrueModuleVersion,
    fetchTrueRunnableVersion,
    renderFetchError,
  )
where

import Data.Generics.Labels ()
import Data.Text qualified as T
import Seihou.Core.Module (RunnableKind (..))
import Seihou.Core.Registry (Registry (..), RegistryEntry (..), RepoContents (..), discoverRepoContents)
import Seihou.Core.Types (AgentPrompt (..), Blueprint (..), Module (..), ModuleName (..))
import Seihou.Dhall.Eval (evalAgentPromptFromFile, evalBlueprintFromFile, evalJudgementFromFile, evalModuleFromFile, evalRegistryFromFile)
import Seihou.Prelude
import System.Directory (doesFileExist)

-- | Reasons a remote-version lookup can fail. These are distinct from
-- "the module declares no version", which is represented by @Right Nothing@
-- in the result of 'fetchTrueModuleVersion'.
data FetchError
  = -- | The repo had no @seihou-registry.dhall@ and no @module.dhall@ at the
    --   root, so the cloned tree exposes nothing we can read. Carries the
    --   path that was checked.
    RegistryNotFound FilePath
  | -- | The repo's @seihou-registry.dhall@ parsed, but no entry whose
    --   @name@ matches the requested module name.
    EntryNotFound ModuleName
  | -- | The path the registry pointed at (or the single-module root) does
    --   not contain a @module.dhall@ file. Carries the absolute path that
    --   was checked.
    ModuleDhallNotFound FilePath
  | -- | The path the registry pointed at (or the single-artifact root) does
    --   not contain the blueprint's or prompt's descriptor file. Carries the
    --   absolute path that was checked.
    DefinitionNotFound FilePath
  | -- | Either @seihou-registry.dhall@ or @module.dhall@ failed to evaluate
    --   or decode. Carries a human-readable description of the failure.
    ParseFailed Text
  deriving stock (Show, Eq)

-- | Read the truthful @version@ field of a module declared in a cloned
-- repository. Reads the module's @module.dhall@ directly, ignoring any
-- @version@ field that the registry might also declare.
--
-- The third state in the result, @Right Nothing@, represents a module whose
-- @module.dhall@ declares @version = None Text@ — i.e., an unversioned
-- module. Callers display this as "unversioned" rather than as an error.
--
-- Behavior depends on what the cloned repo contains, as classified by
-- 'discoverRepoContents':
--
--   * 'MultiModule' — look up the registry entry by name, then read
--     @<clone>/<entry.path>/module.dhall@.
--   * 'SingleModule' — read @<clone>/module.dhall@ regardless of the
--     requested name. (Single-module repos host exactly one module; the
--     name in @module.dhall@ is its own source of truth.)
--   * 'SingleRecipe' or 'EmptyRepo' — no module to read; return
--     'RegistryNotFound'.
fetchTrueModuleVersion :: FilePath -> ModuleName -> IO (Either FetchError (Maybe Text))
fetchTrueModuleVersion clonedRepoPath name = do
  contents <- discoverRepoContents evalRegistryFromFile clonedRepoPath
  case contents of
    SingleModule rootDir ->
      readModuleDhallVersion (rootDir </> "module.dhall")
    MultiModule registry -> case findEntry registry of
      Nothing -> pure (Left (EntryNotFound name))
      Just entry ->
        readModuleDhallVersion (clonedRepoPath </> entry ^. #path </> "module.dhall")
    SingleRecipe _ -> pure (Left (RegistryNotFound clonedRepoPath))
    SingleBlueprint _ -> pure (Left (RegistryNotFound clonedRepoPath))
    SinglePrompt _ -> pure (Left (RegistryNotFound clonedRepoPath))
    SingleJudgement _ -> pure (Left (RegistryNotFound clonedRepoPath))
    EmptyRepo -> pure (Left (RegistryNotFound clonedRepoPath))
  where
    findEntry :: Registry -> Maybe RegistryEntry
    findEntry registry =
      case filter (\e -> e ^. #name == name) (registry ^. #modules) of
        (entry : _) -> Just entry
        [] -> Nothing

-- | Read the truthful @version@ of any installable artifact kind in a cloned
-- repository, from its own descriptor rather than the registry's index.
--
-- A blueprint is found through the registry's @blueprints@ entry of that name,
-- or at the root of a single-blueprint repository; a prompt likewise through
-- @prompts@ or a single-prompt root, and a judgement through @judgements@ or a
-- single-judgement root. Recipes are not version-checked by
-- @seihou outdated@ and report 'RegistryNotFound'.
fetchTrueRunnableVersion :: FilePath -> RunnableKind -> Text -> IO (Either FetchError (Maybe Text))
fetchTrueRunnableVersion clonedRepoPath kind name = case kind of
  KindModule -> fetchTrueModuleVersion clonedRepoPath (ModuleName name)
  KindRecipe -> pure (Left (RegistryNotFound clonedRepoPath))
  KindBlueprint -> located (^. #blueprints) singleBlueprint "blueprint.dhall" $ \path ->
    fmap (^. #version) <$> evalBlueprintFromFile path
  KindPrompt -> located (^. #prompts) singlePrompt "prompt.dhall" $ \path ->
    fmap (^. #version) <$> evalAgentPromptFromFile path
  KindJudgement -> located (^. #judgements) singleJudgement "judgement.dhall" $ \path ->
    fmap (^. #version) <$> evalJudgementFromFile path
  where
    singleBlueprint = \case SingleBlueprint root -> Just root; _ -> Nothing
    singlePrompt = \case SinglePrompt root -> Just root; _ -> Nothing
    singleJudgement = \case SingleJudgement root -> Just root; _ -> Nothing
    located :: (Show e) => (Registry -> [RegistryEntry]) -> (RepoContents -> Maybe FilePath) -> FilePath -> (FilePath -> IO (Either e (Maybe Text))) -> IO (Either FetchError (Maybe Text))
    located entriesOf single file readVersion = do
      contents <- discoverRepoContents evalRegistryFromFile clonedRepoPath
      let directory = case contents of
            MultiModule registry -> case filter (\e -> e ^. #name == ModuleName name) (entriesOf registry) of
              (entry : _) -> Right (clonedRepoPath </> entry ^. #path)
              [] -> Left (EntryNotFound (ModuleName name))
            other -> maybe (Left (RegistryNotFound clonedRepoPath)) Right (single other)
      case directory of
        Left err -> pure (Left err)
        Right dir -> do
          let path = dir </> file
          exists <- doesFileExist path
          if not exists
            then pure (Left (DefinitionNotFound path))
            else either (Left . ParseFailed . T.pack . show) Right <$> readVersion path

-- | Read a @module.dhall@ at the given path and return its @version@ field.
readModuleDhallVersion :: FilePath -> IO (Either FetchError (Maybe Text))
readModuleDhallVersion path = do
  exists <- doesFileExist path
  if not exists
    then pure (Left (ModuleDhallNotFound path))
    else do
      result <- evalModuleFromFile path
      case result of
        Left err -> pure (Left (ParseFailed (T.pack (show err))))
        Right modul -> pure (Right (modul ^. #version))

-- | Render a 'FetchError' as a single-line human-readable message suitable
-- for printing in CLI output ("could not determine remote version: ...").
renderFetchError :: FetchError -> Text
renderFetchError = \case
  RegistryNotFound path ->
    "no module.dhall or seihou-registry.dhall in " <> T.pack path
  EntryNotFound (ModuleName n) ->
    "module '" <> n <> "' not listed in remote registry"
  ModuleDhallNotFound path ->
    "module.dhall not found at " <> T.pack path
  DefinitionNotFound path ->
    "descriptor not found at " <> T.pack path
  ParseFailed msg ->
    "could not parse remote module: " <> msg