seihou-cli-0.11.0.0: src/Seihou/CLI/CommandExecution.hs
module Seihou.CLI.CommandExecution
( CommandPolicy (..),
CommandDisposition (..),
PlannedCommand (..),
CommandPlan (..),
CommandPlanSummary (..),
CommandExecutionError (..),
planCommands,
planCheckoutCommands,
summarizeCommandPlan,
executeCommandPlan,
executeCommandPlanWithOutput,
finalizeCommandReceipts,
CommandOutcome (..),
CommandOutcomeStatus (..),
executeCommandOutcomes,
commandOutcomesFailed,
checkCheckoutWorkDir,
)
where
import Control.Exception (IOException, displayException, try)
import Control.Monad (forM, forM_, when)
import Data.Generics.Labels ()
import Data.List (find)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Text qualified as T
import Data.Time (UTCTime)
import Effectful.Exception qualified as E
import Seihou.Core.CommandFingerprint (checkoutCommandOperation, fingerprintCommand, isCheckoutCommand)
import Seihou.Core.Path (validateCanonicalProjectPath)
import Seihou.Core.Types
import Seihou.Effect.Process (Process, runProcess)
import Seihou.Prelude
import System.Directory qualified as Directory
import System.Exit (ExitCode (..))
import System.FilePath qualified as Path
-- | Whether a command phase runs every declaration, only declarations that
-- lack a successful receipt, or no declarations.
data CommandPolicy
= RunAllCommands
| RunChangedCommands
| DisableCommands
deriving stock (Eq, Show)
-- | The action selected for one rendered command.
data CommandDisposition
= CommandWillRun
| CommandSkippedUnchanged
| CommandSkippedDisabled
deriving stock (Eq, Show)
-- | A rendered command paired with its stable identity and selected action.
data PlannedCommand = PlannedCommand
{ operation :: !Operation,
fingerprint :: !CommandFingerprint,
disposition :: !CommandDisposition
}
deriving stock (Eq, Generic, Show)
-- | An ordered command phase. Declaration/composition order is also execution
-- order.
newtype CommandPlan = CommandPlan
{ commands :: [PlannedCommand]
}
deriving stock (Eq, Generic, Show)
-- | Counts suitable for human or machine-readable previews.
data CommandPlanSummary = CommandPlanSummary
{ willRun :: !Int,
skippedUnchanged :: !Int,
skippedDisabled :: !Int
}
deriving stock (Eq, Generic, Show)
-- | A failed shell command and its captured process result.
data CommandExecutionError = CommandExecutionError
{ command :: !PlannedCommand,
exitCode :: !Int,
stdout :: !Text,
stderr :: !Text
}
deriving stock (Eq, Generic, Show)
-- | Select command dispositions according to policy and prior successful
-- receipts. Non-command operations are ignored.
planCommands ::
CommandPolicy ->
Map CommandFingerprint CommandReceipt ->
[Operation] ->
CommandPlan
planCommands policy priorReceipts =
CommandPlan . mapMaybe planOne
where
planOne operation = do
fingerprint <- fingerprintCommand operation
let disposition = case policy of
RunAllCommands -> CommandWillRun
RunChangedCommands
| not (isCheckoutCommand operation), Map.member fingerprint priorReceipts -> CommandSkippedUnchanged
| otherwise -> CommandWillRun
DisableCommands -> CommandSkippedDisabled
pure PlannedCommand {operation, fingerprint, disposition}
-- | Resolved checkout instructions always run on explicit sync. This boundary
-- accepts neither a receipt map nor an application command policy. Conversion
-- produces only RunCommandOp, for which fingerprintCommand always returns Just.
planCheckoutCommands :: [CheckoutCommand] -> CommandPlan
planCheckoutCommands declarations = CommandPlan (mapMaybe planned declarations)
where
planned declaration = do
let operation = checkoutCommandOperation declaration
fingerprint <- fingerprintCommand operation
pure (PlannedCommand operation fingerprint CommandWillRun)
-- | Count each command disposition without changing command order.
summarizeCommandPlan :: CommandPlan -> CommandPlanSummary
summarizeCommandPlan commandPlan =
foldl' count emptySummary (commandPlan ^. #commands)
where
emptySummary = CommandPlanSummary {willRun = 0, skippedUnchanged = 0, skippedDisabled = 0}
count summary planned = case planned ^. #disposition of
CommandWillRun -> (summary & #willRun %~ (+ 1))
CommandSkippedUnchanged -> (summary & #skippedUnchanged %~ (+ 1))
CommandSkippedDisabled -> summary & #skippedDisabled %~ (+ 1)
-- | Execute runnable commands sequentially with @sh -c@. Stop at the first
-- failure. The caller receives receipts only if the entire phase succeeds.
executeCommandPlan ::
(Process :> es, IOE :> es) =>
UTCTime ->
CommandPlan ->
Eff es (Either CommandExecutionError [CommandReceipt])
executeCommandPlan completedAt commandPlan = go [] (commandPlan ^. #commands)
where
go = executeCommands completedAt (\_ _ _ -> pure ())
-- | Execute a command plan while exposing successful captured output to an
-- effectful callback. The executable uses this to preserve its historical
-- stdout behavior; library callers that only need receipts use
-- 'executeCommandPlan'.
executeCommandPlanWithOutput ::
(Process :> es, IOE :> es) =>
UTCTime ->
(PlannedCommand -> Text -> Text -> Eff es ()) ->
CommandPlan ->
Eff es (Either CommandExecutionError [CommandReceipt])
executeCommandPlanWithOutput completedAt onSuccess commandPlan =
executeCommands completedAt onSuccess [] (commandPlan ^. #commands)
-- | Transient outcomes are never persisted as checkout completion history.
data CommandOutcomeStatus = CommandSucceeded | CommandFailed | CommandSkipped | CommandNotRun
deriving stock (Eq, Show, Generic)
data CommandOutcome = CommandOutcome
{ command :: !PlannedCommand,
status :: !CommandOutcomeStatus,
exitCode :: !(Maybe Int),
stdout :: !Text,
stderr :: !Text
}
deriving stock (Eq, Show, Generic)
commandOutcomesFailed :: [CommandOutcome] -> Bool
commandOutcomesFailed = any ((== CommandFailed) . (^. #status))
-- | Check every component without following symlinks. Repeated immediately
-- before checkout invocations because a preceding shell may change the path.
checkCheckoutWorkDir :: FilePath -> Maybe FilePath -> IO (Either Text ())
checkCheckoutWorkDir root workDir = do
let path = fromMaybe "." workDir
case if path == "." then Right () else validateCanonicalProjectPath (T.pack path) of
Left reason -> pure (Left reason)
Right () -> do
result <- try @IOException $ do
let parts = if path == "." then [] else Path.splitDirectories path
paths = scanl (Path.</>) root parts
safe <-
and
<$> forM
paths
( \part -> do
link <- Directory.pathIsSymbolicLink part
directory <- Directory.doesDirectoryExist part
pure (not link && directory)
)
pure $ if safe then Right () else Left ("checkout command workDir must be an existing real directory with real ancestors: " <> T.pack path)
pure $ either (Left . T.pack . displayException) id result
-- | One shared shell executor. Capture the completed prefix and unrun tail,
-- including launch errors; callers choose receipt publication separately.
executeCommandOutcomes :: (Process :> es, IOE :> es) => CommandPlan -> Eff es [CommandOutcome]
executeCommandOutcomes commandPlan = go False (commandPlan ^. #commands)
where
go _ [] = pure []
go failed (planned : remaining)
| failed = (emptyOutcome planned CommandNotRun :) <$> go True remaining
| planned ^. #disposition /= CommandWillRun = (emptyOutcome planned CommandSkipped :) <$> go False remaining
| otherwise = case planned ^. #operation of
operation@RunCommandOp {command, workDir} -> do
ready <-
if isCheckoutCommand operation
then liftIO (Directory.getCurrentDirectory >>= (\root -> checkCheckoutWorkDir root workDir))
else pure (Right ())
outcome <- case ready of
Left reason -> pure ((emptyOutcome planned CommandFailed) & #stderr .~ reason)
Right () -> do
launched <- E.try @IOException (runProcess "sh" ["-c", command] workDir)
pure $ case launched of
Left err -> (emptyOutcome planned CommandFailed) & #stderr .~ T.pack (displayException err)
Right (processExit, stdout, stderr) -> CommandOutcome planned (if processExit == ExitSuccess then CommandSucceeded else CommandFailed) (Just (case processExit of ExitSuccess -> 0; ExitFailure code -> code)) stdout stderr
(outcome :) <$> go (outcome ^. #status == CommandFailed) remaining
_ -> (emptyOutcome planned CommandSkipped :) <$> go False remaining
emptyOutcome command status = CommandOutcome command status Nothing "" ""
executeCommands ::
(Process :> es, IOE :> es) =>
UTCTime ->
(PlannedCommand -> Text -> Text -> Eff es ()) ->
[CommandReceipt] ->
[PlannedCommand] ->
Eff es (Either CommandExecutionError [CommandReceipt])
executeCommands completedAt onSuccess completed commands = do
outcomes <- executeCommandOutcomes (CommandPlan commands)
forM_ outcomes $ \outcome -> when (outcome ^. #status == CommandSucceeded) (onSuccess (outcome ^. #command) (outcome ^. #stdout) (outcome ^. #stderr))
pure $ case find ((== CommandFailed) . (^. #status)) outcomes of
Just failed -> Left (CommandExecutionError (failed ^. #command) (fromMaybe 127 (failed ^. #exitCode)) (failed ^. #stdout) (failed ^. #stderr))
Nothing -> Right (reverse completed <> mapMaybe receipt outcomes)
where
receipt outcome
| outcome ^. #status /= CommandSucceeded = Nothing
| isCheckoutCommand (outcome ^. #command . #operation) = Nothing
| otherwise = case outcome ^. #command . #operation of
RunCommandOp {moduleName, command, workDir} -> Just (CommandReceipt (outcome ^. #command . #fingerprint) moduleName command workDir completedAt)
_ -> Nothing
-- | Produce the accepted receipt map for the plan's current declaration set.
-- Removed commands are dropped. Fresh successes replace old receipts;
-- unchanged or explicitly disabled declarations retain a matching old receipt.
finalizeCommandReceipts ::
CommandPlan ->
[CommandReceipt] ->
Map CommandFingerprint CommandReceipt ->
Map CommandFingerprint CommandReceipt
finalizeCommandReceipts commandPlan completed priorReceipts =
Map.fromList (mapMaybe receiptFor (commandPlan ^. #commands))
where
completedByFingerprint = Map.fromList [(receipt ^. #fingerprint, receipt) | receipt <- completed]
receiptFor planned
| isCheckoutCommand (planned ^. #operation) = Nothing
| otherwise = case Map.lookup (planned ^. #fingerprint) completedByFingerprint of
Just receipt -> Just (planned ^. #fingerprint, receipt)
Nothing -> case planned ^. #disposition of
CommandWillRun -> Nothing
CommandSkippedUnchanged -> retainPrior planned
CommandSkippedDisabled -> retainPrior planned
retainPrior planned =
(planned ^. #fingerprint,) <$> Map.lookup (planned ^. #fingerprint) priorReceipts