packages feed

seihou-cli-0.4.0.0: src/Seihou/CLI/Migrate.hs

module Seihou.CLI.Migrate
  ( -- * Options
    MigrateOpts (..),

    -- * Handlers
    handleMigrate,
    runMigrate,
    MigrateError (..),
    MigrateResult (..),

    -- * Helpers for upgrade/status integration
    pendingChainFor,

    -- * Auto-commit helper
    commitMigratedFiles,
  )
where

import Control.Monad (unless, when)
import Data.Aeson (ToJSON, object, (.=))
import Data.Aeson qualified as Aeson
import Data.Aeson.Encode.Pretty (encodePretty)
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.Maybe (isJust)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Data.Time.Clock (getCurrentTime)
import Effectful (runEff)
import GHC.Generics (Generic)
import Seihou.CLI.CommitMessage (generateCommitMessage)
import Seihou.CLI.Git (gitAdd, gitCheckIgnore, gitCommit, gitDiffCached, isGitRepo)
import Seihou.CLI.InstallShared
  ( OriginInfo (..),
    cloneRepo,
    installModuleDir,
    readOriginInfo,
  )
import Seihou.CLI.Style (bold, dim, green, red, useColor, yellow)
import Seihou.Core.Migration
  ( Migration (..),
    MigrationOp (..),
    MigrationPlan (..),
    MigrationPlanError (..),
    planMigrationChain,
  )
import Seihou.Core.Registry
  ( Registry (..),
    RegistryEntry (..),
    RepoContents (..),
    discoverRepoContents,
  )
import Seihou.Core.Types
  ( AppliedModule (..),
    Manifest (..),
    Module (..),
    ModuleName (..),
  )
import Seihou.Core.Version (Version, parseVersion, renderVersion)
import Seihou.Dhall.Eval (evalModuleFromFile, evalRegistryFromFile)
import Seihou.Effect.FilesystemInterp (runFilesystem)
import Seihou.Effect.ManifestStore (readManifest, writeManifest)
import Seihou.Effect.ManifestStoreInterp (runManifestStore)
import Seihou.Effect.ProcessInterp (runProcessIO)
import Seihou.Engine.Migrate
  ( ExecutedMigrationPlan (..),
    MigrationExecError (..),
    MigrationFileStatus (..),
    MigrationOpInstance (..),
    classifyMigration,
    executeMigration,
  )
import Seihou.Prelude
import System.Directory (doesFileExist)
import System.Exit (ExitCode (..), exitFailure, exitSuccess)
import System.FilePath (takeFileName, (</>))
import System.IO (stderr)
import System.IO.Temp (withSystemTempDirectory)

-- ----------------------------------------------------------------------------
-- Options
-- ----------------------------------------------------------------------------

-- | Options for @seihou migrate@: applies module-declared migrations to
-- the current project's working tree and manifest.
data MigrateOpts = MigrateOpts
  { migrateModule :: ModuleName,
    -- | Override the target version. Defaults to the installed
    -- module's current version (i.e. "migrate up to where the
    -- installed copy is now"). Under the gap-tolerant planner, the
    -- target is just the manifest's landing version: every declared
    -- migration whose @to@ does not exceed the target is applied,
    -- and the manifest advances to the target on success.
    migrateTo :: Maybe Text,
    migrateDryRun :: Bool,
    -- | Proceed even when the plan touches files the user has edited
    -- since they were generated. Mirrors @seihou remove --force@.
    migrateForce :: Bool,
    migrateJson :: Bool,
    migrateVerbose :: Bool,
    -- | Skip the default fetch-and-refresh step that clones the module's
    -- source repo and refreshes @~/.config/seihou/installed/<name>/@
    -- before planning the chain. When 'True', the command performs no
    -- network IO and uses only the locally installed copy as the source
    -- of truth. Default: 'False' (fetch is the new default after EP-2;
    -- before EP-2 the only behavior was local-only).
    migrateNoFetch :: Bool,
    -- | When 'True', and only on success branches that mutated the
    -- project's working tree, stage the touched files plus
    -- @.seihou/manifest.json@ and create a git commit. The commit
    -- message is supplied by 'migrateCommitMessage' if set, otherwise
    -- generated by 'Seihou.CLI.CommitMessage.generateCommitMessage'.
    -- Has no effect for dry-run or no-op outcomes. Has no effect
    -- outside a git work tree.
    migrateCommit :: Bool,
    -- | Custom commit message; implies @migrateCommit = True@.
    -- When 'Nothing', the AI-generated message is used.
    migrateCommitMessage :: Maybe Text
  }
  deriving stock (Eq, Show, Generic)

-- ----------------------------------------------------------------------------
-- Errors and results
-- ----------------------------------------------------------------------------

-- | Reasons the @seihou migrate@ handler exits non-zero. Each variant
-- carries a human-readable message that the IO shell prints and uses
-- as its error message.
data MigrateError
  = MigrateNoManifest FilePath
  | MigrateModuleNotApplied ModuleName
  | MigrateNoRecordedVersion ModuleName
  | MigrateInstalledModuleEvalFailed FilePath Text
  | MigrateInstalledModuleHasNoVersion ModuleName FilePath
  | MigrateUnparseableInstalledVersion Text
  | MigrateUnparseableTargetVersion Text
  | MigrateUnparseableManifestVersion Text
  | MigratePlanFailed MigrationPlanError
  | MigrateExecFailed MigrationExecError
  deriving stock (Eq, Show, Generic)

-- | Outcome of a successful @runMigrate@ call.
--
-- Three shapes only:
--
--   * 'MigrateNoOp' — the manifest already records the target version;
--     no work to do.
--   * 'MigrateApplied' — the manifest advanced from @from@ to @to@.
--     The carried 'ExecutedMigrationPlan' lists every step that
--     actually ran; it may be empty (a pure version bump where no
--     declared migration falls in the @[from, to]@ window).
--   * 'MigrateDryRunOK' — dry-run companion of 'MigrateApplied'.
data MigrateResult
  = MigrateNoOp Version
  | MigrateApplied ExecutedMigrationPlan Manifest Version Version
  | MigrateDryRunOK ExecutedMigrationPlan Version Version
  deriving stock (Eq, Show, Generic)

-- ----------------------------------------------------------------------------
-- IO shell
-- ----------------------------------------------------------------------------

-- | The IO entry point dispatched from @main@. Loads the manifest,
-- delegates planning + execution to 'runMigrate' (which handles the
-- fetch-and-refresh dance unless @--no-fetch@ was passed), and renders
-- the result.
handleMigrate :: MigrateOpts -> IO ()
handleMigrate opts = do
  let manifestPath = ".seihou" </> "manifest.json"
      modName = opts.migrateModule

  manifestRes <- runEff $ runFilesystem $ runManifestStore manifestPath readManifest
  manifest <- case manifestRes of
    Left err -> die (MigrateNoManifest (T.unpack err))
    Right Nothing -> die (MigrateNoManifest manifestPath)
    Right (Just m) -> pure m

  applied <- case findApplied manifest modName of
    Nothing -> die (MigrateModuleNotApplied modName)
    Just am -> pure am

  _fromV <- case applied.moduleVersion >>= parseVersion of
    Just v -> pure v
    Nothing -> case applied.moduleVersion of
      Nothing -> die (MigrateNoRecordedVersion modName)
      Just t -> die (MigrateUnparseableManifestVersion t)

  result <- runMigrate opts manifest applied.source

  colorEnabled <- useColor
  case result of
    Left err -> die err
    Right (MigrateNoOp toV) -> do
      TIO.putStrLn $
        applyColor colorEnabled green "✓"
          <> " "
          <> modName.unModuleName
          <> " is already at version "
          <> renderVersion toV
          <> "; nothing to do."
      exitSuccess
    Right (MigrateDryRunOK plan fromV toV) -> do
      if opts.migrateJson
        then LBS.putStr (encodePretty (planToJson plan))
        else do
          renderPlan colorEnabled plan fromV toV
          TIO.putStrLn ""
          TIO.putStrLn $ applyColor colorEnabled dim "(dry run — no changes made)"
      exitSuccess
    Right (MigrateApplied plan manifest' fromV toV) -> do
      if opts.migrateJson
        then LBS.putStr (encodePretty (planToJson plan))
        else renderPlan colorEnabled plan fromV toV
      runEff $
        runFilesystem $
          runManifestStore manifestPath $
            writeManifest manifest'
      when (opts.migrateCommit || isJust opts.migrateCommitMessage) $
        commitMigratedFiles opts manifestPath plan
      unless opts.migrateJson $ do
        TIO.putStrLn ""
        TIO.putStrLn $
          applyColor colorEnabled green "✓"
            <> " Migrated "
            <> applyColor colorEnabled bold modName.unModuleName
            <> " "
            <> renderVersion fromV
            <> " → "
            <> renderVersion toV
            <> "."

-- | The non-IO core of the handler. Useful as a building block for
-- other CLI surfaces (e.g. @seihou upgrade --with-migrations@) that
-- already have a manifest and an installed-module dir in hand.
--
-- Behavior depends on @opts.migrateNoFetch@:
--
--   * @True@ — operate purely on the supplied @installedDir@. This is
--     the legacy behavior used by @seihou upgrade --with-migrations@,
--     which has already refreshed the installed copy.
--   * @False@ (default) — read @<installedDir>\/.seihou-origin.json@,
--     clone the source repository shallowly, use the clone's module
--     dir as the source of truth for planning the chain, and on a
--     successful non-dry-run application refresh @installedDir@ from
--     the clone via 'installModuleDir'. Failures (missing origin file,
--     clone failure, module not present in remote) silently fall back
--     to the local-only path.
runMigrate ::
  MigrateOpts ->
  Manifest ->
  -- | Installed-module directory, the path that holds @module.dhall@
  FilePath ->
  IO (Either MigrateError MigrateResult)
runMigrate opts manifest installedDir
  | opts.migrateNoFetch = runMigrateLocal opts manifest installedDir
  | otherwise = runMigrateWithFetch opts manifest installedDir

-- | Plan and (optionally) execute a migration chain using @sourceDir@
-- as the source of truth for the module's @module.dhall@ and migrations
-- list. Performs no network IO.
runMigrateLocal ::
  MigrateOpts ->
  Manifest ->
  -- | Directory holding the module's @module.dhall@ — either the
  -- locally installed copy or a freshly cloned moduleDir.
  FilePath ->
  IO (Either MigrateError MigrateResult)
runMigrateLocal opts manifest sourceDir = do
  let modName = opts.migrateModule
  case findApplied manifest modName of
    Nothing -> pure (Left (MigrateModuleNotApplied modName))
    Just applied -> case applied.moduleVersion of
      Nothing -> pure (Left (MigrateNoRecordedVersion modName))
      Just fromText ->
        case parseVersion fromText of
          Nothing -> pure (Left (MigrateUnparseableManifestVersion fromText))
          Just fromV -> do
            let sourceDhall = sourceDir </> "module.dhall"
            exists <- doesFileExist sourceDhall
            if not exists
              then
                pure
                  ( Left
                      ( MigrateInstalledModuleEvalFailed
                          sourceDhall
                          "module.dhall not found at installed dir"
                      )
                  )
              else do
                r <- evalModuleFromFile sourceDhall
                case r of
                  Left err ->
                    pure
                      ( Left
                          ( MigrateInstalledModuleEvalFailed
                              sourceDhall
                              (T.pack (show err))
                          )
                      )
                  Right sourceModule -> case resolveTarget opts sourceModule modName sourceDhall of
                    Left e -> pure (Left e)
                    Right toV ->
                      case planMigrationChain
                        modName.unModuleName
                        sourceModule.migrations
                        fromV
                        toV of
                        Left e -> pure (Left (MigratePlanFailed e))
                        Right Nothing -> pure (Right (MigrateNoOp toV))
                        Right (Just plan) ->
                          dispatchPlan opts manifest plan

-- | The fetch-and-refresh wrapper. Reads @<installedDir>\/.seihou-origin.json@,
-- clones the source repo to a temp dir, locates the module within the
-- clone, and dispatches to 'runMigrateLocal' with the cloned module
-- dir. On a successful non-dry-run apply, refreshes @installedDir@
-- from the clone so the next @seihou status@/@migrate@ call sees the
-- new version locally.
--
-- Any soft failure in the fetch path (missing origin metadata, clone
-- failure, module not in remote) emits a one-line note (unless JSON
-- output is requested, in which case the path stays silent so JSON
-- consumers aren't disturbed) and falls back to 'runMigrateLocal'
-- against the original 'installedDir'.
runMigrateWithFetch ::
  MigrateOpts ->
  Manifest ->
  FilePath ->
  IO (Either MigrateError MigrateResult)
runMigrateWithFetch opts manifest installedDir = do
  origin <- readOriginInfo installedDir
  case origin of
    Nothing -> do
      note opts $
        "  no origin metadata at "
          <> T.pack (installedDir </> ".seihou-origin.json")
          <> "; using locally installed copy."
      runMigrateLocal opts manifest installedDir
    Just o -> withSystemTempDirectory "seihou-migrate-fetch" $ \tmp -> do
      let cloneDir = tmp </> "clone"
      note opts ("  Fetching " <> o.sourceUrl <> "...")
      cloneRes <- cloneRepo o.sourceUrl cloneDir
      case cloneRes of
        Left err -> do
          note opts ("  fetch failed: " <> err <> "; using locally installed copy.")
          runMigrateLocal opts manifest installedDir
        Right () -> do
          contents <- discoverRepoContents evalRegistryFromFile cloneDir
          case findRemoteModuleDir cloneDir contents opts.migrateModule of
            Nothing -> do
              note opts $
                "  module '"
                  <> opts.migrateModule.unModuleName
                  <> "' not present in remote; using locally installed copy."
              runMigrateLocal opts manifest installedDir
            Just (moduleDir, tags) -> do
              -- Plan and execute against the clone's module dir. The
              -- chain operates on the project's working tree; the
              -- moduleDir only supplies the migrations list and the
              -- target version.
              cloneResult <- runMigrateLocal opts manifest moduleDir
              -- EP-27 / EP-35 M5: when the local install declares more
              -- in-window migrations than the clone, prefer the local
              -- plan. The user's installed copy may still declare an
              -- applicable edge that the remote dropped; honour it
              -- rather than silently skip.
              result <- maybeFallbackToLocal opts manifest installedDir cloneResult
              -- Refresh the installed copy on the disk so future
              -- commands see the new version locally. Any successful
              -- apply (with or without ops) updates the disk; dry-run
              -- and no-op outcomes leave it untouched.
              case result of
                Right (MigrateApplied {})
                  | not opts.migrateDryRun ->
                      refreshInstalledFromClone moduleDir installedDir o tags
                _ -> pure ()
              pure result

-- | If the local install yields a plan with strictly more in-window
-- migrations than the clone-based plan, prefer the local result.
-- Otherwise the clone's plan stands. The check fires on any successful
-- result (Applied, DryRunOK, NoOp); errors are passed through
-- unchanged.
maybeFallbackToLocal ::
  MigrateOpts ->
  Manifest ->
  FilePath ->
  Either MigrateError MigrateResult ->
  IO (Either MigrateError MigrateResult)
maybeFallbackToLocal opts manifest installedDir cloneResult = case cloneResult of
  Left _ -> pure cloneResult
  Right cloneOk -> do
    localResult <- runMigrateLocal opts manifest installedDir
    pure $ case localResult of
      Right localOk
        | resultStepCount localOk > resultStepCount cloneOk -> localResult
      _ -> cloneResult

-- | Number of migration steps a result actually carries (or would
-- carry, in dry-run form). Used by 'maybeFallbackToLocal'.
resultStepCount :: MigrateResult -> Int
resultStepCount = \case
  MigrateNoOp {} -> 0
  MigrateApplied execPlan _ _ _ -> length execPlan.planSource.planSteps
  MigrateDryRunOK execPlan _ _ -> length execPlan.planSource.planSteps

-- | Print a one-line note unless JSON output is requested. Using JSON
-- output requires a clean, parseable stdout.
note :: MigrateOpts -> Text -> IO ()
note opts msg = unless opts.migrateJson (TIO.putStrLn msg)

-- | Locate the module's directory inside a cloned repo and return any
-- registry-declared tags. Returns 'Nothing' for empty repos or
-- recipe-only repos, or for multi-module repos that do not list the
-- requested module.
findRemoteModuleDir ::
  FilePath ->
  RepoContents ->
  ModuleName ->
  Maybe (FilePath, [Text])
findRemoteModuleDir cloneDir contents modName = case contents of
  SingleModule rootDir -> Just (rootDir, [])
  MultiModule registry ->
    case filter (\e -> e.name == modName) registry.modules of
      (entry : _) -> Just (cloneDir </> entry.path, entry.tags)
      [] -> Nothing
  SingleRecipe _ -> Nothing
  SingleBlueprint _ -> Nothing
  SinglePrompt _ -> Nothing
  EmptyRepo -> Nothing

-- | Refresh the on-disk installed module directory from a cloned
-- module dir. Reads the clone's @module.dhall@ for its declared
-- version, then calls 'installModuleDir' with the same name as the
-- existing installation (basename of @installedDir@) so the XDG-derived
-- destination matches the original install path.
refreshInstalledFromClone ::
  FilePath ->
  FilePath ->
  OriginInfo ->
  [Text] ->
  IO ()
refreshInstalledFromClone moduleDir installedDir origin tags = do
  let dhallFile = moduleDir </> "module.dhall"
  modulRes <- evalModuleFromFile dhallFile
  case modulRes of
    Left _ -> pure ()
    Right modul -> do
      let installedName = takeFileName installedDir
      installModuleDir
        moduleDir
        installedName
        origin.sourceUrl
        origin.repoName
        modul.version
        tags

-- ----------------------------------------------------------------------------
-- Pending-migration detection (used by status / upgrade)
-- ----------------------------------------------------------------------------

-- | Detect whether the manifest's recorded version of an applied module
-- has fallen behind the installed copy. Returns:
--
--   * @Nothing@ — versions equal, manifest version unrecorded,
--     installed-module version unrecorded, parse failure anywhere, or
--     the planner reported a hard error.
--   * @Just plan@ — a non-trivial plan whose 'planSteps' may be empty
--     (a pure version bump) or non-empty.
--
-- Parse failures and planner errors are treated as "no pending
-- migration" rather than surfaced — this is the soft-warning path for
-- @seihou status@. Hard errors land on the user when they actually
-- run @seihou migrate@.
pendingChainFor ::
  AppliedModule ->
  Module ->
  Maybe MigrationPlan
pendingChainFor applied installed = do
  fromText <- applied.moduleVersion
  fromV <- parseVersion fromText
  toText <- installed.version
  toV <- parseVersion toText
  case planMigrationChain
    applied.name.unModuleName
    installed.migrations
    fromV
    toV of
    Right (Just plan) -> Just plan
    _ -> Nothing

-- ----------------------------------------------------------------------------
-- Helpers
-- ----------------------------------------------------------------------------

-- | Decide what to do with a 'MigrationPlan' returned by the planner.
-- Pure-IO: classifies the plan, optionally executes it, and packs the
-- outcome into a 'MigrateResult' variant the renderer knows how to
-- print.
--
-- The plan from the gap-tolerant walker is always one shape — an
-- ordered list of in-window migrations plus the supplied @planFrom@
-- and @planTo@. The dispatcher branches only on @planFrom == planTo@
-- (no work) and dry-run vs apply.
dispatchPlan ::
  MigrateOpts ->
  Manifest ->
  MigrationPlan ->
  IO (Either MigrateError MigrateResult)
dispatchPlan opts manifest plan
  | plan.planFrom == plan.planTo = pure (Right (MigrateNoOp plan.planTo))
  | otherwise = applyOrDryRun opts manifest plan

-- | Classify the plan, optionally execute it, and return the
-- appropriate 'MigrateResult' variant. Empty 'planSteps' is fine —
-- 'executeMigration' runs zero file ops but still advances the
-- manifest's recorded @moduleVersion@ to @planTo@.
applyOrDryRun ::
  MigrateOpts ->
  Manifest ->
  MigrationPlan ->
  IO (Either MigrateError MigrateResult)
applyOrDryRun opts manifest plan = do
  classifyResult <-
    runEff $
      runFilesystem $
        classifyMigration manifest plan
  case classifyResult of
    Left err -> pure (Left (MigrateExecFailed err))
    Right executedPlan ->
      if opts.migrateDryRun
        then pure (Right (MigrateDryRunOK executedPlan plan.planFrom plan.planTo))
        else do
          now <- getCurrentTime
          execRes <-
            runEff $
              runFilesystem $
                runProcessIO $
                  executeMigration opts.migrateForce executedPlan manifest now
          case execRes of
            Left err -> pure (Left (MigrateExecFailed err))
            Right manifest' ->
              pure (Right (MigrateApplied executedPlan manifest' plan.planFrom plan.planTo))

-- | Stage and commit the files touched by a successful migration plan.
-- Mirrors the @seihou run --commit@ post-execution helper. No-op
-- outside a git work tree, when every staged path is git-ignored, or
-- when both flags are off (callers gate on the flags before invoking
-- this). 'RunCommandInst' ops contribute no paths — their filesystem
-- effects are opaque, so any extra working-tree changes need a
-- separate manual commit.
commitMigratedFiles ::
  MigrateOpts ->
  -- | Manifest path (added to the staged set).
  FilePath ->
  ExecutedMigrationPlan ->
  IO ()
commitMigratedFiles opts manifestPath plan = do
  let touched = concatMap pathsForOp plan.planOps
      filesToStage = touched ++ [manifestPath]
  inGit <- runEff $ runProcessIO isGitRepo
  when inGit $ do
    ignored <- runEff $ runProcessIO $ gitCheckIgnore filesToStage
    let staged = filter (`notElem` ignored) filesToStage
    unless (null staged) $ do
      (addExit, _, addErr) <- runEff $ runProcessIO $ gitAdd staged
      case addExit of
        ExitFailure _ -> TIO.hPutStrLn stderr ("git add failed: " <> addErr)
        ExitSuccess -> do
          msg <- case opts.migrateCommitMessage of
            Just m -> pure m
            Nothing -> do
              diffText <- runEff $ runProcessIO gitDiffCached
              generateCommitMessage [opts.migrateModule] diffText
          (cExit, _, cErr) <- runEff $ runProcessIO $ gitCommit msg
          case cExit of
            ExitSuccess -> pure ()
            ExitFailure _ ->
              TIO.hPutStrLn stderr ("git commit failed: " <> cErr)
  where
    pathsForOp inst = case inst of
      MoveFileInst src dest _ -> [src, dest]
      MoveDirInst src dest -> [src, dest]
      DeleteFileInst path _ -> [path]
      DeleteDirInst path -> [path]
      RunCommandInst _ _ -> []

resolveTarget ::
  MigrateOpts -> Module -> ModuleName -> FilePath -> Either MigrateError Version
resolveTarget opts installedModule modName installedDhall =
  case opts.migrateTo of
    Just t -> case parseVersion t of
      Just v -> Right v
      Nothing -> Left (MigrateUnparseableTargetVersion t)
    Nothing -> case installedModule.version of
      Nothing -> Left (MigrateInstalledModuleHasNoVersion modName installedDhall)
      Just t -> case parseVersion t of
        Just v -> Right v
        Nothing -> Left (MigrateUnparseableInstalledVersion t)

findApplied :: Manifest -> ModuleName -> Maybe AppliedModule
findApplied m name =
  case filter (\am -> am.name == name) m.modules of
    (am : _) -> Just am
    [] -> Nothing

-- | Render a classified plan to stdout in the human-readable format.
-- The supplied versions are the user-visible "X → Y" header taken from
-- the source plan's @planFrom@ and @planTo@; the manifest will land at
-- @planTo@ even when 'planSteps' is empty.
renderPlan :: Bool -> ExecutedMigrationPlan -> Version -> Version -> IO ()
renderPlan c plan fromV toV = do
  let src = plan.planSource
  TIO.putStrLn $
    "Migration plan: "
      <> applyColor c bold (src.planModule)
      <> "  "
      <> renderVersion fromV
      <> " → "
      <> renderVersion toV
  if null src.planSteps
    then TIO.putStrLn "  (no migration ops)"
    else mapM_ (renderStep c) src.planSteps
  let conflictCount =
        length
          [ () | inst <- plan.planOps, isConflict inst
          ]
      affectedCount =
        length
          [ () | inst <- plan.planOps, touchesFs inst
          ]
  TIO.putStrLn ""
  TIO.putStrLn $
    T.pack (show affectedCount)
      <> " operation(s), "
      <> T.pack (show conflictCount)
      <> " conflict(s)."
  where
    isConflict (MoveFileInst _ _ MFConflict) = True
    isConflict (DeleteFileInst _ MFConflict) = True
    isConflict _ = False

    touchesFs (RunCommandInst _ _) = True
    touchesFs _ = True

renderStep :: Bool -> Migration -> IO ()
renderStep c step = do
  TIO.putStrLn $ "  " <> step.from <> " → " <> step.to <> ":"
  mapM_ (renderOp c) step.ops

renderOp :: Bool -> MigrationOp -> IO ()
renderOp c op = case op of
  MoveFile {src, dest} ->
    TIO.putStrLn $ "    " <> applyColor c green "move-file " <> T.pack src <> " -> " <> T.pack dest
  MoveDir {src, dest} ->
    TIO.putStrLn $ "    " <> applyColor c green "move-dir  " <> T.pack src <> " -> " <> T.pack dest
  DeleteFile {path} ->
    TIO.putStrLn $ "    " <> applyColor c yellow "delete    " <> T.pack path
  DeleteDir {path} ->
    TIO.putStrLn $ "    " <> applyColor c yellow "delete-dir" <> " " <> T.pack path
  RunCommand {run, workDir} ->
    let suffix = case workDir of
          Just wd -> applyColor c dim (" (in " <> T.pack wd <> ")")
          Nothing -> ""
     in TIO.putStrLn $ "    " <> applyColor c green "run       " <> run <> suffix

-- ----------------------------------------------------------------------------
-- JSON output
-- ----------------------------------------------------------------------------

planToJson :: ExecutedMigrationPlan -> Aeson.Value
planToJson plan =
  let src = plan.planSource
   in object
        [ "module" .= plan.planModule.unModuleName,
          "from" .= renderVersion src.planFrom,
          "to" .= renderVersion src.planTo,
          "steps"
            .= [ object
                   [ "from" .= step.from,
                     "to" .= step.to,
                     "ops" .= map opToJson step.ops
                   ]
               | step <- src.planSteps
               ],
          "operations" .= map instToJson plan.planOps
        ]

opToJson :: MigrationOp -> Aeson.Value
opToJson op = case op of
  MoveFile {src, dest} ->
    object ["op" .= ("move-file" :: Text), "src" .= T.pack src, "dest" .= T.pack dest]
  MoveDir {src, dest} ->
    object ["op" .= ("move-dir" :: Text), "src" .= T.pack src, "dest" .= T.pack dest]
  DeleteFile {path} ->
    object ["op" .= ("delete-file" :: Text), "path" .= T.pack path]
  DeleteDir {path} ->
    object ["op" .= ("delete-dir" :: Text), "path" .= T.pack path]
  RunCommand {run, workDir} ->
    object ["op" .= ("run-command" :: Text), "run" .= run, "workDir" .= fmap T.pack workDir]

instToJson :: MigrationOpInstance -> Aeson.Value
instToJson inst = case inst of
  MoveFileInst src dest status ->
    object
      [ "op" .= ("move-file" :: Text),
        "src" .= T.pack src,
        "dest" .= T.pack dest,
        "status" .= statusToJson status
      ]
  MoveDirInst src dest ->
    object
      ["op" .= ("move-dir" :: Text), "src" .= T.pack src, "dest" .= T.pack dest]
  DeleteFileInst p status ->
    object
      [ "op" .= ("delete-file" :: Text),
        "path" .= T.pack p,
        "status" .= statusToJson status
      ]
  DeleteDirInst p ->
    object ["op" .= ("delete-dir" :: Text), "path" .= T.pack p]
  RunCommandInst run wd ->
    object ["op" .= ("run-command" :: Text), "run" .= run, "workDir" .= fmap T.pack wd]

statusToJson :: MigrationFileStatus -> Text
statusToJson MFSafe = "safe"
statusToJson MFConflict = "conflict"
statusToJson MFGone = "gone"

-- ----------------------------------------------------------------------------
-- Misc
-- ----------------------------------------------------------------------------

-- | Apply ANSI styling when colour is enabled.
applyColor :: Bool -> (Text -> Text) -> Text -> Text
applyColor True f = f
applyColor False _ = id

-- | Print the error and exit non-zero. The IO shell calls this; the
-- pure 'runMigrate' returns 'Left' instead.
die :: MigrateError -> IO a
die err = do
  c <- useColor
  TIO.putStrLn $ applyColor c red "Error: " <> renderError err
  exitFailure

renderError :: MigrateError -> Text
renderError (MigrateNoManifest path) =
  "no Seihou manifest at " <> T.pack path <> "; run from a project that has been initialized."
renderError (MigrateModuleNotApplied modName) =
  "module '" <> modName.unModuleName <> "' is not applied in this project."
renderError (MigrateNoRecordedVersion modName) =
  "module '"
    <> modName.unModuleName
    <> "' has no version recorded in the manifest. Re-apply the module with 'seihou run' to record one before migrating."
renderError (MigrateInstalledModuleEvalFailed path msg) =
  "could not evaluate installed module at " <> T.pack path <> ": " <> msg
renderError (MigrateInstalledModuleHasNoVersion modName path) =
  "installed module '"
    <> modName.unModuleName
    <> "' at "
    <> T.pack path
    <> " has no version field; either pass --to or add a version to its module.dhall."
renderError (MigrateUnparseableInstalledVersion v) =
  "installed module's version '" <> v <> "' is not a valid dotted version."
renderError (MigrateUnparseableTargetVersion v) =
  "--to value '" <> v <> "' is not a valid dotted version."
renderError (MigrateUnparseableManifestVersion v) =
  "manifest's recorded module version '" <> v <> "' is not a valid dotted version."
renderError (MigratePlanFailed e) = renderPlanError e
renderError (MigrateExecFailed e) = renderExecError e

renderPlanError :: MigrationPlanError -> Text
renderPlanError (MigrationVersionUnparseable t) =
  "a migration declares an unparseable version: '" <> t <> "'"
renderPlanError (MigrationDowngradeNotSupported installed target) =
  "downgrade not supported: installed "
    <> renderVersion installed
    <> ", requested target "
    <> renderVersion target
    <> "."
renderPlanError (MigrationDuplicateEdge fromV _) =
  "two or more migrations declare the same 'from = "
    <> renderVersion fromV
    <> "'. The chain is ambiguous; the module author needs to merge or remove the duplicate."

renderExecError :: MigrationExecError -> Text
renderExecError (MigrationConflict paths) =
  "the following file(s) have been modified since they were generated:\n"
    <> T.intercalate "\n" ["  - " <> T.pack p | p <- paths]
    <> "\n\nRe-run with --force to overwrite them, or revert your edits first."
renderExecError (MigrationCommandFailed msg code) =
  "a run-command op exited with code "
    <> T.pack (show code)
    <> ":\n  "
    <> msg
renderExecError (MigrationUnsafePath label path reason) =
  "unsafe migration "
    <> label
    <> " '"
    <> T.pack path
    <> "': "
    <> reason

-- Aeson ToJSON pass-through for ExecutedMigrationPlan: routed through
-- planToJson manually rather than deriving so we have the field shape
-- documented above.
instance ToJSON ExecutedMigrationPlan where
  toJSON = planToJson