packages feed

seihou-cli-0.6.0.0: src/Seihou/CLI/PendingMigrations.hs

module Seihou.CLI.PendingMigrations
  ( detectPendingMigrations,
    formatRefusalMessage,
  )
where

import Data.Generics.Labels ()
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text qualified as T
import Seihou.CLI.Migrate (pendingChainFor)
import Seihou.CLI.Shared (resolveAppliedArtifactDir)
import Seihou.Core.Migration (MigrationPlan (..))
import Seihou.Core.Types
  ( AppliedModule (..),
    Manifest (..),
    ModuleName (..),
  )
import Seihou.Core.Version (renderVersion)
import Seihou.Dhall.Eval (evalModuleFromFile)
import Seihou.Prelude

-- | Detect pending migrations across applied modules in a manifest.
--
-- For each applied module whose installed @module.dhall@ declares a
-- newer version than the manifest's recorded version, return the
-- migration plan that describes the gap. Under the gap-tolerant
-- planner the plan always advances the manifest to the supplied
-- target on apply; the carried 'planSteps' may be empty (a pure
-- version bump where no declared migration falls in the window) or
-- non-empty.
--
-- IO failures (missing @module.dhall@, eval errors, parse failures) are
-- silently skipped: pending-migration reporting is best-effort and never
-- raises.
--
-- The optional filter restricts detection to a subset of module names.
-- 'Nothing' means "consider every applied module" (used by @seihou
-- status@). @'Just' names@ keeps only modules whose name is in the set
-- (used by @seihou run@, which only blocks when a module it is about
-- to write into has a pending plan).
detectPendingMigrations ::
  Manifest ->
  Maybe (Set ModuleName) ->
  IO [(ModuleName, MigrationPlan)]
detectPendingMigrations manifest mFilter =
  fmap
    (\xs -> [(name, p) | (name, Just p) <- xs])
    (mapM check candidates)
  where
    candidates = case mFilter of
      Nothing -> (manifest ^. #modules)
      Just names -> filter (\am -> Set.member (am ^. #name) names) (manifest ^. #modules)

    -- The manifest records a portable origin, so the module has to be located
    -- on this machine first. A module that does not resolve here is skipped
    -- like any other read failure: detection is best-effort, and the command
    -- that actually needs the module reports the resolution error properly.
    check am = do
      resolved <- resolveAppliedArtifactDir "module.dhall" (am ^. #origin)
      case resolved of
        Left _ -> pure (am ^. #name, Nothing)
        Right directory -> do
          let dhallFile = directory </> "module.dhall"
          r <- evalModuleFromFile dhallFile
          case r of
            Left _ -> pure (am ^. #name, Nothing)
            Right installed -> pure (am ^. #name, pendingChainFor am installed)

-- | Format the user-facing refusal message that @seihou run@ prints
-- when it detects pending migrations and the user has not opted into
-- @--with-migrations@. Each row reports the module, the version range
-- the migration would cover, and how many migration ops it would run
-- (which may be zero — a pure version bump).
formatRefusalMessage :: [(ModuleName, MigrationPlan)] -> Text
formatRefusalMessage pendings =
  T.unlines $
    "Pending migrations detected:"
      : map renderEntry pendings
      ++ [ "",
           "For a recorded project application, run 'seihou update <target>'.",
           "For focused recovery, run 'seihou migrate <module>' for each, or pass --with-migrations to this explicit reconfiguration run."
         ]
  where
    renderEntry (name, plan) =
      "  "
        <> name ^. #unModuleName
        <> ": "
        <> renderVersion (plan ^. #from)
        <> " -> "
        <> renderVersion (plan ^. #to)
        <> " ("
        <> T.pack (show (length (plan ^. #steps)))
        <> " step(s))"