packages feed

seihou-cli-0.4.0.0: src/Seihou/CLI/Registry/Sync.hs

module Seihou.CLI.Registry.Sync
  ( SyncVersionsOpts (..),
    SyncAction (..),
    SyncOutcome (..),
    runSync,
    handleSyncVersions,
    renderSyncReport,
    checkRegistryVersionDrift,
    resolveOnDiskVersions,
  )
where

import Data.Maybe (mapMaybe)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import GHC.Generics (Generic)
import Seihou.Core.Registry
  ( EntryKind (..),
    Registry (..),
    RegistryEntry (..),
    RepoContents (..),
    SyncDiff (..),
    SyncReport (..),
    SyncStatus (..),
    computeRegistrySync,
    discoverRepoContents,
    formatDriftWarning,
    renderRegistryDhall,
  )
import Seihou.Core.Types (AgentPrompt, Blueprint, Module, ModuleName (..), Recipe)
import Seihou.Core.Types qualified as Types
import Seihou.Dhall.Eval
  ( evalAgentPromptFromFile,
    evalBlueprintFromFile,
    evalModuleFromFile,
    evalRecipeFromFile,
    evalRegistryFromFile,
  )
import Seihou.Prelude
import System.Directory (doesDirectoryExist)
import System.Exit (ExitCode (..), exitWith)
import System.FilePath ((</>))
import System.IO (hPutStrLn, stderr)

-- | Flags parsed for the @seihou registry sync-versions@ subcommand.
data SyncVersionsOpts = SyncVersionsOpts
  { syncVersionsDir :: Maybe FilePath,
    syncVersionsDryRun :: Bool,
    syncVersionsCheck :: Bool
  }
  deriving stock (Eq, Show, Generic)

-- | What the sync pass did with the registry file.
data SyncAction
  = -- | Registry file rewritten on disk.
    Wrote
  | -- | @--dry-run@: diff computed, file left untouched.
    WouldWrite
  | -- | @--check@: diff computed, file left untouched, exit code reflects drift.
    Checked
  deriving stock (Eq, Show, Generic)

-- | Terminal outcome of a sync run, decoupled from IO concerns like printing
-- and 'exitWith' so tests can assert without capturing stdout.
data SyncOutcome
  = -- | Registry loaded, diff computed, action applied.
    SyncSuccess SyncReport SyncAction
  | -- | Target invalid (directory missing, no registry file, etc.); carries a
    -- human-readable error.
    SyncFailure Text
  deriving stock (Eq, Show, Generic)

-- | Testable core of @seihou registry sync-versions@. Locates the registry,
-- reads each entry's on-disk version, computes the diff, writes the file
-- when appropriate, and returns a structured outcome.
runSync :: SyncVersionsOpts -> IO SyncOutcome
runSync opts = do
  let targetDir = maybe "." id opts.syncVersionsDir
  dirExists <- doesDirectoryExist targetDir
  if not dirExists
    then pure (SyncFailure ("target directory does not exist: " <> T.pack targetDir))
    else do
      contents <- discoverRepoContents evalRegistryFromFile targetDir
      case contents of
        MultiModule reg -> do
          lookups <- resolveOnDiskVersions targetDir reg
          let report = computeRegistrySync reg lookups
          let checkMode = opts.syncVersionsCheck
              dryRun = opts.syncVersionsDryRun && not checkMode
              writeMode = not checkMode && not dryRun
          action <-
            if writeMode
              then do
                let rendered = renderRegistryDhall report.syncUpdated
                TIO.writeFile (targetDir </> "seihou-registry.dhall") rendered
                pure Wrote
              else
                if checkMode
                  then pure Checked
                  else pure WouldWrite
          pure (SyncSuccess report action)
        _ ->
          pure
            ( SyncFailure
                "registry sync-versions requires a seihou-registry.dhall at the target directory"
            )

-- | Read each registry entry's runnable Dhall file and pair it with the
-- entry's @(kind, name)@. Entries whose file fails to evaluate are omitted
-- from the returned list, which causes 'computeRegistrySync' to classify them
-- as 'SyncOrphan'.
resolveOnDiskVersions ::
  FilePath ->
  Registry ->
  IO [(EntryKind, ModuleName, Maybe Text)]
resolveOnDiskVersions repoRoot reg = do
  modulePairs <- mapM (loadModule repoRoot) reg.modules
  recipePairs <- mapM (loadRecipe repoRoot) reg.recipes
  blueprintPairs <- mapM (loadBlueprint repoRoot) reg.blueprints
  promptPairs <- mapM (loadPrompt repoRoot) reg.prompts
  pure (concat modulePairs <> concat recipePairs <> concat blueprintPairs <> concat promptPairs)
  where
    loadModule :: FilePath -> RegistryEntry -> IO [(EntryKind, ModuleName, Maybe Text)]
    loadModule root entry = do
      let path = root </> entry.path </> "module.dhall"
      decoded <- evalModuleFromFile path
      case decoded of
        Right m -> pure [(ModuleEntry, entry.name, moduleVersion m)]
        Left _ -> pure []
    loadRecipe :: FilePath -> RegistryEntry -> IO [(EntryKind, ModuleName, Maybe Text)]
    loadRecipe root entry = do
      let path = root </> entry.path </> "recipe.dhall"
      decoded <- evalRecipeFromFile path
      case decoded of
        Right r -> pure [(RecipeEntry, entry.name, recipeVersion r)]
        Left _ -> pure []
    loadBlueprint :: FilePath -> RegistryEntry -> IO [(EntryKind, ModuleName, Maybe Text)]
    loadBlueprint root entry = do
      let path = root </> entry.path </> "blueprint.dhall"
      decoded <- evalBlueprintFromFile path
      case decoded of
        Right b -> pure [(BlueprintEntry, entry.name, blueprintVersion b)]
        Left _ -> pure []
    loadPrompt :: FilePath -> RegistryEntry -> IO [(EntryKind, ModuleName, Maybe Text)]
    loadPrompt root entry = do
      let path = root </> entry.path </> "prompt.dhall"
      decoded <- evalAgentPromptFromFile path
      case decoded of
        Right p -> pure [(PromptEntry, entry.name, promptVersion p)]
        Left _ -> pure []

-- | Extract the @version@ field from a 'Module' by pattern match. A direct
-- record-dot access (@m.version@) fails under 'DuplicateRecordFields' +
-- 'NoFieldSelectors' in this module because 'RegistryEntry' and 'Module'
-- share the field name.
moduleVersion :: Module -> Maybe Text
moduleVersion Types.Module {Types.version = v} = v

-- | Analogous accessor for 'Recipe.version'. See 'moduleVersion'.
recipeVersion :: Recipe -> Maybe Text
recipeVersion Types.Recipe {Types.version = v} = v

-- | Analogous accessor for 'Blueprint.version'. See 'moduleVersion'.
blueprintVersion :: Blueprint -> Maybe Text
blueprintVersion Types.Blueprint {Types.version = v} = v

-- | Analogous accessor for 'AgentPrompt.version'. See 'moduleVersion'.
promptVersion :: AgentPrompt -> Maybe Text
promptVersion Types.AgentPrompt {Types.version = v} = v

-- | Handler wired into the CLI command dispatcher. Drives 'runSync', prints a
-- human-readable diff, and exits with 0 or 1 as appropriate.
handleSyncVersions :: SyncVersionsOpts -> IO ()
handleSyncVersions opts = do
  outcome <- runSync opts
  case outcome of
    SyncFailure msg -> do
      hPutStrLn stderr ("error: " <> T.unpack msg)
      exitWith (ExitFailure 1)
    SyncSuccess report action -> do
      TIO.putStr (renderSyncReport report)
      case action of
        Wrote -> exitWith ExitSuccess
        WouldWrite -> exitWith ExitSuccess
        Checked ->
          if anyDrift report
            then exitWith (ExitFailure 1)
            else exitWith ExitSuccess

-- | Format the diff table and summary line for display on stdout.
-- The first entry in 'syncDiffs' appears first, preserving registry order.
renderSyncReport :: SyncReport -> Text
renderSyncReport report
  | null report.syncDiffs =
      "Registry is empty.\n"
  | otherwise =
      T.unlines $
        header
          : map ("  " <>) rows
            <> ["", summary report]
  where
    header = "Updated seihou-registry.dhall:"
    rows = map renderRow report.syncDiffs

    renderRow :: SyncDiff -> Text
    renderRow diff =
      let label = kindPrefix diff.diffKind <> diff.diffName.unModuleName <> ":"
          padded = padRight labelWidth label
          old = renderVersion diff.diffOld
          new = renderVersion diff.diffNew
          arrow = case diff.diffStatus of
            SyncInSync -> " == " <> new <> " (no change)"
            SyncOrphan -> " ?? " <> old <> " (" <> entryFile diff.diffKind <> " missing)"
            _ -> " -> " <> new
       in padded <> old <> arrow

    labelWidth = maximum (24 : map diffLabelWidth report.syncDiffs)
    diffLabelWidth d =
      T.length (kindPrefix d.diffKind <> d.diffName.unModuleName) + 2

kindPrefix :: EntryKind -> Text
kindPrefix ModuleEntry = "modules."
kindPrefix RecipeEntry = "recipes."
kindPrefix BlueprintEntry = "blueprints."
kindPrefix PromptEntry = "prompts."

entryFile :: EntryKind -> Text
entryFile ModuleEntry = "module.dhall"
entryFile RecipeEntry = "recipe.dhall"
entryFile BlueprintEntry = "blueprint.dhall"
entryFile PromptEntry = "prompt.dhall"

renderVersion :: Maybe Text -> Text
renderVersion Nothing = "(none)"
renderVersion (Just v) = v

padRight :: Int -> Text -> Text
padRight w t =
  let pad = max 0 (w - T.length t)
   in t <> T.replicate pad " "

summary :: SyncReport -> Text
summary report =
  let updated = length [d | d <- report.syncDiffs, changesVersion d.diffStatus]
      orphans = length [d | d <- report.syncDiffs, d.diffStatus == SyncOrphan]
      unchanged = length [d | d <- report.syncDiffs, d.diffStatus == SyncInSync]
      base =
        T.pack (show updated)
          <> " "
          <> pluralize updated "entry" "entries"
          <> " updated, "
          <> T.pack (show unchanged)
          <> " unchanged"
      suffix =
        if orphans > 0
          then ", " <> T.pack (show orphans) <> " orphan" <> (if orphans > 1 then "s" else "")
          else ""
   in base <> suffix <> "."
  where
    changesVersion SyncMissing = True
    changesVersion (SyncStale _) = True
    changesVersion _ = False
    pluralize 1 s _ = s
    pluralize _ _ p = p

anyDrift :: SyncReport -> Bool
anyDrift report =
  any
    ( \d -> case d.diffStatus of
        SyncInSync -> False
        _ -> True
    )
    report.syncDiffs

-- | Soft-warning pass: compare each registry entry's 'version' with the
-- on-disk module.dhall / recipe.dhall and return one warning per out-of-sync
-- entry. Entries whose file is missing or unparseable are treated as orphan
-- and excluded here, matching 'formatDriftWarning' (they are already flagged
-- by 'validateRegistry').
--
-- Intended to be called after 'discoverRepoContents' yields 'MultiModule' —
-- the caller has already decoded the registry and knows the repo root.
checkRegistryVersionDrift :: FilePath -> Registry -> IO [Text]
checkRegistryVersionDrift repoRoot reg = do
  lookups <- resolveOnDiskVersions repoRoot reg
  let report = computeRegistrySync reg lookups
  pure (mapMaybe formatDriftWarning report.syncDiffs)