packages feed

kioku-cli-0.4.0.0: src/Kioku/Cli/Commands/Artifacts.hs

-- | @kioku migrate-artifacts@: move the pre-partition workspace mirrors into a memory space.
--
-- Before memory spaces existed, scene and persona mirrors were written to @.kioku\/scenes@ and
-- @.kioku\/persona@, keyed by scope alone. Two spaces holding the same scope would have written
-- to the same file, so the layout is now @.kioku\/spaces\/\<space-dir\>\/{scenes,persona}@ and
-- nothing writes to the old tree any more. This command relocates what is already there.
--
-- It is a dry run unless @--apply@ is passed. That default is the point: the command exists so
-- an operator can read exactly which file would land where, and see any collision, before
-- anything is written.
module Kioku.Cli.Commands.Artifacts
  ( ArtifactsOptions (..),
    artifactsOptionsParser,
    runArtifacts,
  )
where

import Control.Monad (when)
import Data.Text qualified as Text
import Kioku.Api.Access (memorySpaceIdText)
import Kioku.Cli.Context (cliMemorySpace)
import Kioku.Workspace
  ( ArtifactMove (..),
    MoveVerdict (..),
    applyArtifactMigration,
    planArtifactMigration,
  )
import Options.Applicative
import System.Directory (getCurrentDirectory)
import System.Exit (ExitCode (..), exitWith)

data ArtifactsOptions = ArtifactsOptions
  { workspace :: !(Maybe FilePath),
    apply :: !Bool
  }
  deriving stock (Eq, Show)

artifactsOptionsParser :: Parser ArtifactsOptions
artifactsOptionsParser =
  ArtifactsOptions
    <$> optional
      ( strOption
          ( long "workspace"
              <> metavar "DIR"
              <> help "Workspace holding .kioku (default: the current directory)"
          )
      )
    <*> switch
      ( long "apply"
          <> help "Copy the files (default: report what would happen and write nothing)"
      )

-- | The destination space comes from @KIOKU_MEMORY_SPACE@, which defaults to @kioku_legacy@.
--
-- That default is the same rule the database backfill follows: every artifact in the historical
-- tree was written before the partition existed, so it belongs to the one explicit legacy space
-- unless the operator says otherwise. See @docs\/adr\/legacy-data-lands-in-one-explicit-space.md@.
runArtifacts :: ArtifactsOptions -> IO ()
runArtifacts opts = do
  space <- cliMemorySpace
  workspace <- maybe getCurrentDirectory pure opts.workspace
  moves <- planArtifactMigration workspace space
  putStrLn
    ( "kioku artifact migration ("
        <> (if opts.apply then "apply" else "dry run")
        <> ") for memory space "
        <> Text.unpack (memorySpaceIdText space)
    )
  if null moves
    then putStrLn "  (no pre-partition scene or persona mirrors found)"
    else mapM_ (putStrLn . renderMove) moves
  when opts.apply (applyArtifactMigration moves)
  putStrLn (summarize moves)
  -- A collision is a refusal, and a refusal a script cannot see is not a refusal. It is
  -- reported in dry-run mode too, because the whole purpose of the dry run is to find out
  -- before applying.
  when (any ((== MoveCollision) . (.verdict)) moves) (exitWith (ExitFailure 1))

renderMove :: ArtifactMove -> String
renderMove move =
  "  " <> verdictLabel move.verdict <> "  " <> move.source <> " -> " <> move.destination

verdictLabel :: MoveVerdict -> String
verdictLabel = \case
  MoveReady -> "copy     "
  MoveAlreadyMigrated -> "migrated "
  MoveCollision -> "COLLISION"

summarize :: [ArtifactMove] -> String
summarize moves =
  show (count MoveReady)
    <> " to copy, "
    <> show (count MoveAlreadyMigrated)
    <> " already migrated, "
    <> show (count MoveCollision)
    <> " refused as collisions."
  where
    count verdict = length (filter ((== verdict) . (.verdict)) moves)