seihou-core-0.4.0.0: src/Seihou/Core/Registry.hs
module Seihou.Core.Registry
( Registry (..),
RegistryEntry (..),
RepoContents (..),
discoverRepoContents,
validateRegistry,
renderRegistryDhall,
EntryKind (..),
SyncStatus (..),
SyncDiff (..),
SyncReport (..),
computeRegistrySync,
formatDriftWarning,
RegistryValidationIssue (..),
RegistryValidationReport (..),
reportHasIssues,
validateRegistryFull,
formatValidationIssue,
)
where
import Data.Text qualified as T
import GHC.Generics (Generic)
import Seihou.Core.Types (ModuleLoadError, ModuleName (..))
import Seihou.Prelude
import System.Directory (doesFileExist)
-- | A single module listing within a registry.
data RegistryEntry = RegistryEntry
{ name :: ModuleName,
version :: Maybe Text,
path :: FilePath,
description :: Maybe Text,
tags :: [Text]
}
deriving stock (Eq, Show, Generic)
-- | Registry metadata for a multi-module repository.
-- Declared in @seihou-registry.dhall@ at the repo root.
data Registry = Registry
{ repoName :: Text,
repoDescription :: Maybe Text,
modules :: [RegistryEntry],
recipes :: [RegistryEntry],
blueprints :: [RegistryEntry],
prompts :: [RegistryEntry]
}
deriving stock (Eq, Show, Generic)
-- | What a cloned repository contains.
data RepoContents
= -- | Repo root has @module.dhall@ (single module)
SingleModule FilePath
| -- | Repo root has @recipe.dhall@ (single recipe)
SingleRecipe FilePath
| -- | Repo root has @blueprint.dhall@ (single blueprint)
SingleBlueprint FilePath
| -- | Repo root has @prompt.dhall@ (single prompt)
SinglePrompt FilePath
| -- | Repo root has @seihou-registry.dhall@
MultiModule Registry
| -- | Neither found
EmptyRepo
deriving stock (Show)
-- | Examine a cloned repo root and determine what it contains.
-- The first argument is a function that evaluates a registry Dhall file,
-- injected to avoid a circular module dependency with @Seihou.Dhall.Eval@.
discoverRepoContents ::
(FilePath -> IO (Either ModuleLoadError Registry)) ->
FilePath ->
IO RepoContents
discoverRepoContents evalRegistry repoRoot = do
let registryFile = repoRoot </> "seihou-registry.dhall"
hasRegistry <- doesFileExist registryFile
if hasRegistry
then do
result <- evalRegistry registryFile
case result of
Right reg -> pure (MultiModule reg)
Left _ -> probeSingleArtifact repoRoot
else probeSingleArtifact repoRoot
-- | Probe for a single-artifact repo at @repoRoot@. Precedence:
-- @module.dhall@ → @recipe.dhall@ → @blueprint.dhall@ → @prompt.dhall@ → 'EmptyRepo'.
-- A stray @module.dhall@ next to a @blueprint.dhall@ resolves as the
-- module: the more specific, deterministic artifact wins.
probeSingleArtifact :: FilePath -> IO RepoContents
probeSingleArtifact repoRoot = do
let moduleFile = repoRoot </> "module.dhall"
recipeFile = repoRoot </> "recipe.dhall"
blueprintFile = repoRoot </> "blueprint.dhall"
promptFile = repoRoot </> "prompt.dhall"
hasModule <- doesFileExist moduleFile
if hasModule
then pure (SingleModule repoRoot)
else do
hasRecipe <- doesFileExist recipeFile
if hasRecipe
then pure (SingleRecipe repoRoot)
else do
hasBlueprint <- doesFileExist blueprintFile
if hasBlueprint
then pure (SingleBlueprint repoRoot)
else do
hasPrompt <- doesFileExist promptFile
if hasPrompt
then pure (SinglePrompt repoRoot)
else pure EmptyRepo
-- | Validate a registry's entries against the filesystem.
-- Returns a list of error messages (empty means valid).
-- Checks: module name format, path safety, entry file existence,
-- and no name collisions between modules, recipes, blueprints, and prompts.
validateRegistry :: FilePath -> Registry -> IO [Text]
validateRegistry repoRoot reg = do
modErrs <- concat <$> mapM (validateModuleEntry repoRoot) reg.modules
recErrs <- concat <$> mapM (validateRecipeEntry repoRoot) reg.recipes
bpErrs <- concat <$> mapM (validateBlueprintEntry repoRoot) reg.blueprints
promptErrs <- concat <$> mapM (validatePromptEntry repoRoot) reg.prompts
let collisionErrs = checkNameCollisions reg.modules reg.recipes reg.blueprints reg.prompts
pure (modErrs <> recErrs <> bpErrs <> promptErrs <> collisionErrs)
validateModuleEntry :: FilePath -> RegistryEntry -> IO [Text]
validateModuleEntry repoRoot entry = do
let nameText = entry.name.unModuleName
nameErrors = checkName nameText
pathText = T.pack entry.path
pathErrors = checkPath pathText
let moduleDhall = repoRoot </> entry.path </> "module.dhall"
fileExists <- doesFileExist moduleDhall
let fileErrors =
if fileExists
then []
else ["registry entry '" <> nameText <> "' points to missing module.dhall at " <> pathText]
pure (nameErrors <> pathErrors <> fileErrors)
where
checkName name
| validModuleName name = []
| otherwise = ["registry entry name must match [a-z][a-z0-9-]*, got: " <> name]
checkPath path
| T.isPrefixOf "/" path = ["registry entry path must be relative: " <> path]
| ".." `T.isInfixOf` path = ["registry entry path must not contain '..': " <> path]
| otherwise = []
validateRecipeEntry :: FilePath -> RegistryEntry -> IO [Text]
validateRecipeEntry repoRoot entry = do
let nameText = entry.name.unModuleName
nameErrors = checkRecipeName nameText
pathText = T.pack entry.path
pathErrors = checkRecipePath pathText
let recipeDhall = repoRoot </> entry.path </> "recipe.dhall"
fileExists <- doesFileExist recipeDhall
let fileErrors =
if fileExists
then []
else ["registry recipe entry '" <> nameText <> "' points to missing recipe.dhall at " <> pathText]
pure (nameErrors <> pathErrors <> fileErrors)
where
checkRecipeName name
| validModuleName name = []
| otherwise = ["registry recipe name must match [a-z][a-z0-9-]*, got: " <> name]
checkRecipePath path
| T.isPrefixOf "/" path = ["registry recipe path must be relative: " <> path]
| ".." `T.isInfixOf` path = ["registry recipe path must not contain '..': " <> path]
| otherwise = []
validateBlueprintEntry :: FilePath -> RegistryEntry -> IO [Text]
validateBlueprintEntry repoRoot entry = do
let nameText = entry.name.unModuleName
nameErrors = checkBlueprintName nameText
pathText = T.pack entry.path
pathErrors = checkBlueprintPath pathText
let blueprintDhall = repoRoot </> entry.path </> "blueprint.dhall"
fileExists <- doesFileExist blueprintDhall
let fileErrors =
if fileExists
then []
else ["registry blueprint entry '" <> nameText <> "' points to missing blueprint.dhall at " <> pathText]
pure (nameErrors <> pathErrors <> fileErrors)
where
checkBlueprintName name
| validModuleName name = []
| otherwise = ["registry blueprint name must match [a-z][a-z0-9-]*, got: " <> name]
checkBlueprintPath path
| T.isPrefixOf "/" path = ["registry blueprint path must be relative: " <> path]
| ".." `T.isInfixOf` path = ["registry blueprint path must not contain '..': " <> path]
| otherwise = []
validatePromptEntry :: FilePath -> RegistryEntry -> IO [Text]
validatePromptEntry repoRoot entry = do
let nameText = entry.name.unModuleName
nameErrors = checkPromptName nameText
pathText = T.pack entry.path
pathErrors = checkPromptPath pathText
let promptDhall = repoRoot </> entry.path </> "prompt.dhall"
fileExists <- doesFileExist promptDhall
let fileErrors =
if fileExists
then []
else ["registry prompt entry '" <> nameText <> "' points to missing prompt.dhall at " <> pathText]
pure (nameErrors <> pathErrors <> fileErrors)
where
checkPromptName name
| validModuleName name = []
| otherwise = ["registry prompt name must match [a-z][a-z0-9-]*, got: " <> name]
checkPromptPath path
| T.isPrefixOf "/" path = ["registry prompt path must be relative: " <> path]
| ".." `T.isInfixOf` path = ["registry prompt path must not contain '..': " <> path]
| otherwise = []
-- | Detect name collisions across module, recipe, blueprint, and prompt entries.
-- Each cross-kind pair sharing a name produces one message; a name that
-- appears in all four kinds produces six messages (one per pair).
checkNameCollisions :: [RegistryEntry] -> [RegistryEntry] -> [RegistryEntry] -> [RegistryEntry] -> [Text]
checkNameCollisions mods recs bps prompts =
let modNames = map (\e -> e.name.unModuleName) mods
recNames = map (\e -> e.name.unModuleName) recs
bpNames = map (\e -> e.name.unModuleName) bps
promptNames = map (\e -> e.name.unModuleName) prompts
modRec = [n | n <- modNames, n `elem` recNames]
modBp = [n | n <- modNames, n `elem` bpNames]
modPrompt = [n | n <- modNames, n `elem` promptNames]
recBp = [n | n <- recNames, n `elem` bpNames]
recPrompt = [n | n <- recNames, n `elem` promptNames]
bpPrompt = [n | n <- bpNames, n `elem` promptNames]
in map (\n -> "name collision: '" <> n <> "' appears as both a module and a recipe") modRec
<> map (\n -> "name collision: '" <> n <> "' appears as both a module and a blueprint") modBp
<> map (\n -> "name collision: '" <> n <> "' appears as both a module and a prompt") modPrompt
<> map (\n -> "name collision: '" <> n <> "' appears as both a recipe and a blueprint") recBp
<> map (\n -> "name collision: '" <> n <> "' appears as both a recipe and a prompt") recPrompt
<> map (\n -> "name collision: '" <> n <> "' appears as both a blueprint and a prompt") bpPrompt
-- | Check that a text matches @[a-z][a-z0-9-]*@.
-- Duplicated from @Seihou.Core.Module.isValidModuleName@ to avoid
-- a circular module dependency through @Seihou.Dhall.Eval@.
validModuleName :: Text -> Bool
validModuleName 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
-- | What a registry entry points at on disk.
data EntryKind = ModuleEntry | RecipeEntry | BlueprintEntry | PromptEntry
deriving stock (Eq, Show, Generic)
-- | Classification of a single registry entry relative to the on-disk
-- @module.dhall@/@recipe.dhall@ version.
data SyncStatus
= -- | Registry @version@ is 'Nothing', on-disk version is @Just _@.
SyncMissing
| -- | Registry @version@ and on-disk version both @Just@ but differ;
-- carries the new on-disk version.
SyncStale Text
| -- | Registry and on-disk versions match (both 'Just' equal or both 'Nothing').
SyncInSync
| -- | On-disk @module.dhall@/@recipe.dhall@ absent or unreadable.
SyncOrphan
deriving stock (Eq, Show, Generic)
-- | One row of a sync diff, preserving registry order.
data SyncDiff = SyncDiff
{ diffKind :: EntryKind,
diffName :: ModuleName,
diffOld :: Maybe Text,
diffNew :: Maybe Text,
diffStatus :: SyncStatus
}
deriving stock (Eq, Show, Generic)
-- | Output of 'computeRegistrySync': the classifications in registry order,
-- and a 'Registry' with each entry's @version@ field updated to the on-disk
-- value (except 'SyncOrphan' entries, which are preserved as-is).
data SyncReport = SyncReport
{ syncDiffs :: [SyncDiff],
syncUpdated :: Registry
}
deriving stock (Eq, Show, Generic)
-- | Pure core of the version-sync flow. The caller resolves each entry's
-- on-disk version ('Just v', or 'Nothing' for orphaned/unreadable entries)
-- and passes them in; this function classifies and rewrites the registry
-- without performing any IO.
--
-- The lookup list is keyed by @(kind, name)@; entries absent from the list
-- are classified as 'SyncOrphan'.
computeRegistrySync ::
Registry ->
[(EntryKind, ModuleName, Maybe Text)] ->
SyncReport
computeRegistrySync reg lookups =
SyncReport
{ syncDiffs = moduleDiffs <> recipeDiffs <> blueprintDiffs <> promptDiffs,
syncUpdated =
reg
{ modules = zipWith applyDiff moduleDiffs reg.modules,
recipes = zipWith applyDiff recipeDiffs reg.recipes,
blueprints = zipWith applyDiff blueprintDiffs reg.blueprints,
prompts = zipWith applyDiff promptDiffs reg.prompts
}
}
where
moduleDiffs = map (classify ModuleEntry) reg.modules
recipeDiffs = map (classify RecipeEntry) reg.recipes
blueprintDiffs = map (classify BlueprintEntry) reg.blueprints
promptDiffs = map (classify PromptEntry) reg.prompts
classify :: EntryKind -> RegistryEntry -> SyncDiff
classify kind entry =
let onDisk = lookupOnDisk kind entry.name
status = case (entry.version, onDisk) of
(_, OnDiskMissing) -> SyncOrphan
(Nothing, OnDiskValue Nothing) -> SyncInSync
(Nothing, OnDiskValue (Just _)) -> SyncMissing
(Just _, OnDiskValue Nothing) -> SyncInSync
(Just old, OnDiskValue (Just new))
| old == new -> SyncInSync
| otherwise -> SyncStale new
newVersion = case onDisk of
OnDiskMissing -> entry.version
OnDiskValue v -> v
in SyncDiff
{ diffKind = kind,
diffName = entry.name,
diffOld = entry.version,
diffNew = newVersion,
diffStatus = status
}
applyDiff :: SyncDiff -> RegistryEntry -> RegistryEntry
applyDiff diff entry = case diff.diffStatus of
SyncOrphan -> entry
_ -> entry {version = diff.diffNew}
lookupOnDisk :: EntryKind -> ModuleName -> OnDiskVersion
lookupOnDisk kind name =
case [v | (k, n, v) <- lookups, k == kind, n == name] of
[] -> OnDiskMissing
(v : _) -> OnDiskValue v
-- | Three-way state used internally by 'computeRegistrySync' to distinguish
-- \"entry absent from the lookup list\" (orphan) from \"entry present, version
-- field is @Nothing@\" (in-sync with an unversioned module.dhall).
data OnDiskVersion = OnDiskMissing | OnDiskValue (Maybe Text)
-- | Format a single 'SyncDiff' as a human-readable drift warning, or
-- 'Nothing' if the entry is already in sync. Used by @seihou browse@ and
-- @seihou install@ to surface stale registry versions without blocking.
formatDriftWarning :: SyncDiff -> Maybe Text
formatDriftWarning diff = case diff.diffStatus of
SyncInSync -> Nothing
SyncOrphan -> Nothing
SyncMissing ->
Just $
kindWord diff.diffKind
<> " '"
<> diff.diffName.unModuleName
<> "' registry version is missing; "
<> entryFile diff.diffKind
<> " declares "
<> renderVersion diff.diffNew
<> " — run `seihou registry sync-versions`"
SyncStale newVer ->
Just $
kindWord diff.diffKind
<> " '"
<> diff.diffName.unModuleName
<> "' registry version "
<> renderVersion diff.diffOld
<> " differs from "
<> entryFile diff.diffKind
<> " version "
<> newVer
<> " — run `seihou registry sync-versions`"
where
renderVersion Nothing = "(none)"
renderVersion (Just v) = v
kindWord ModuleEntry = "module"
kindWord RecipeEntry = "recipe"
kindWord BlueprintEntry = "blueprint"
kindWord PromptEntry = "prompt"
entryFile ModuleEntry = "module.dhall"
entryFile RecipeEntry = "recipe.dhall"
entryFile BlueprintEntry = "blueprint.dhall"
entryFile PromptEntry = "prompt.dhall"
-- | One row of the unified validation report. Either reuses an existing
-- structural error message (path/name/file-existence/collisions) or
-- carries a version-mismatch row classified by 'SyncStatus'.
data RegistryValidationIssue
= StructuralError Text
| VersionMismatch SyncDiff
deriving stock (Eq, Show, Generic)
-- | Whole-registry validation outcome, carrying every issue plus the
-- entry counts used by the human-readable summary line.
data RegistryValidationReport = RegistryValidationReport
{ reportIssues :: [RegistryValidationIssue],
reportModuleCount :: Int,
reportRecipeCount :: Int,
reportBlueprintCount :: Int,
reportPromptCount :: Int
}
deriving stock (Eq, Show, Generic)
-- | True iff the report has at least one issue.
reportHasIssues :: RegistryValidationReport -> Bool
reportHasIssues r = not (null r.reportIssues)
-- | Combine the existing structural checks with version classification.
-- The third argument is the same shape 'computeRegistrySync' takes —
-- the caller loads each entry's @module.dhall@/@recipe.dhall@ once and
-- passes a lookup list keyed by @(kind, name)@.
validateRegistryFull ::
FilePath ->
Registry ->
[(EntryKind, ModuleName, Maybe Text)] ->
IO RegistryValidationReport
validateRegistryFull repoRoot reg lookups = do
structuralErrs <- validateRegistry repoRoot reg
let report = computeRegistrySync reg lookups
versionIssues =
[ VersionMismatch d
| d <- report.syncDiffs,
isVersionDrift d.diffStatus
]
pure
RegistryValidationReport
{ reportIssues = map StructuralError structuralErrs <> versionIssues,
reportModuleCount = length reg.modules,
reportRecipeCount = length reg.recipes,
reportBlueprintCount = length reg.blueprints,
reportPromptCount = length reg.prompts
}
where
isVersionDrift SyncMissing = True
isVersionDrift (SyncStale _) = True
isVersionDrift _ = False
-- | Render a single 'RegistryValidationIssue' as a one-line human-readable
-- string. 'VersionMismatch' rows reuse the @modules.foo@/@recipes.bar@
-- prefix used by @sync-versions@ but omit the trailing
-- "run @seihou registry sync-versions@" suggestion — the validate handler
-- prints a single aggregated suggestion at the bottom.
formatValidationIssue :: RegistryValidationIssue -> Text
formatValidationIssue (StructuralError msg) = msg
formatValidationIssue (VersionMismatch diff) =
validationKindPrefix diff.diffKind
<> diff.diffName.unModuleName
<> ": registry version "
<> validationRenderVersion diff.diffOld
<> " does not match "
<> entryFile diff.diffKind
<> " version "
<> validationRenderVersion diff.diffNew
where
entryFile ModuleEntry = "module.dhall"
entryFile RecipeEntry = "recipe.dhall"
entryFile BlueprintEntry = "blueprint.dhall"
entryFile PromptEntry = "prompt.dhall"
validationKindPrefix :: EntryKind -> Text
validationKindPrefix ModuleEntry = "modules."
validationKindPrefix RecipeEntry = "recipes."
validationKindPrefix BlueprintEntry = "blueprints."
validationKindPrefix PromptEntry = "prompts."
validationRenderVersion :: Maybe Text -> Text
validationRenderVersion Nothing = "(none)"
validationRenderVersion (Just v) = v
-- | Serialize a 'Registry' as a Dhall record literal compatible with
-- 'Seihou.Dhall.Eval.registryDecoder'. Rewrites lose hand-written comments
-- and formatting; see @docs/plans/12-sync-registry-versions.md@ for rationale.
renderRegistryDhall :: Registry -> Text
renderRegistryDhall reg =
T.unlines
[ "{ repoName = " <> renderString reg.repoName,
", repoDescription = " <> renderOptionalText reg.repoDescription,
", modules =",
renderEntryList reg.modules,
", recipes =",
renderEntryList reg.recipes,
", blueprints =",
renderEntryList reg.blueprints,
", prompts =",
renderEntryList reg.prompts,
"}"
]
renderEntryList :: [RegistryEntry] -> Text
renderEntryList [] =
" [] : List { name : Text, version : Optional Text, path : Text, description : Optional Text, tags : List Text }"
renderEntryList entries =
T.intercalate
"\n"
(zipWith renderEntry (True : repeat False) entries <> [" ]"])
renderEntry :: Bool -> RegistryEntry -> Text
renderEntry isFirst entry =
T.intercalate
"\n"
[ " " <> opener <> " { name = " <> renderString entry.name.unModuleName,
" , version = " <> renderOptionalText entry.version,
" , path = " <> renderString (T.pack entry.path),
" , description = " <> renderOptionalText entry.description,
" , tags = " <> renderTextList entry.tags,
" }"
]
where
opener = if isFirst then "[" else ","
renderOptionalText :: Maybe Text -> Text
renderOptionalText Nothing = "None Text"
renderOptionalText (Just v) = "Some " <> renderString v
renderTextList :: [Text] -> Text
renderTextList [] = "[] : List Text"
renderTextList xs = "[ " <> T.intercalate ", " (map renderString xs) <> " ]"
-- | Render a Dhall double-quoted string literal.
-- Escapes backslashes, double quotes, dollar signs (for interpolation),
-- and common control characters per the Dhall spec.
renderString :: Text -> Text
renderString t = "\"" <> T.concatMap escape t <> "\""
where
escape '\\' = "\\\\"
escape '"' = "\\\""
escape '$' = "\\$"
escape '\n' = "\\n"
escape '\r' = "\\r"
escape '\t' = "\\t"
escape c = T.singleton c