seihou-cli-0.5.0.0: src/Seihou/CLI/CommandExecution.hs
module Seihou.CLI.CommandExecution
( CommandPolicy (..),
CommandDisposition (..),
PlannedCommand (..),
CommandPlan (..),
CommandPlanSummary (..),
CommandExecutionError (..),
planCommands,
summarizeCommandPlan,
executeCommandPlan,
executeCommandPlanWithOutput,
finalizeCommandReceipts,
)
where
import Data.Map.Strict qualified as Map
import Data.Maybe (mapMaybe)
import Data.Time (UTCTime)
import Seihou.Core.CommandFingerprint (fingerprintCommand)
import Seihou.Core.Types
import Seihou.Effect.Process (Process, runProcess)
import Seihou.Prelude
import System.Exit (ExitCode (..))
-- | 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, Show)
-- | An ordered command phase. Declaration/composition order is also execution
-- order.
newtype CommandPlan = CommandPlan
{ commands :: [PlannedCommand]
}
deriving stock (Eq, Show)
-- | Counts suitable for human or machine-readable previews.
data CommandPlanSummary = CommandPlanSummary
{ willRun :: Int,
skippedUnchanged :: Int,
skippedDisabled :: Int
}
deriving stock (Eq, Show)
-- | A failed shell command and its captured process result.
data CommandExecutionError = CommandExecutionError
{ command :: PlannedCommand,
exitCode :: Int,
stdout :: Text,
stderr :: Text
}
deriving stock (Eq, 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
| Map.member fingerprint priorReceipts -> CommandSkippedUnchanged
| otherwise -> CommandWillRun
DisableCommands -> CommandSkippedDisabled
pure PlannedCommand {operation, fingerprint, disposition}
-- | 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 = summary.willRun + 1}
CommandSkippedUnchanged -> summary {skippedUnchanged = summary.skippedUnchanged + 1}
CommandSkippedDisabled -> summary {skippedDisabled = 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) =>
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) =>
UTCTime ->
(PlannedCommand -> Text -> Text -> Eff es ()) ->
CommandPlan ->
Eff es (Either CommandExecutionError [CommandReceipt])
executeCommandPlanWithOutput completedAt onSuccess commandPlan =
executeCommands completedAt onSuccess [] commandPlan.commands
executeCommands ::
(Process :> es) =>
UTCTime ->
(PlannedCommand -> Text -> Text -> Eff es ()) ->
[CommandReceipt] ->
[PlannedCommand] ->
Eff es (Either CommandExecutionError [CommandReceipt])
executeCommands completedAt onSuccess = go
where
go completed [] = pure (Right (reverse completed))
go completed (planned : remaining) = case planned.disposition of
CommandSkippedUnchanged -> go completed remaining
CommandSkippedDisabled -> go completed remaining
CommandWillRun -> case planned.operation of
RunCommandOp {command, workDir, moduleName} -> do
(processExit, stdout, stderr) <- runProcess "sh" ["-c", command] workDir
case processExit of
ExitSuccess -> do
onSuccess planned stdout stderr
let receipt =
CommandReceipt
{ fingerprint = planned.fingerprint,
moduleName,
command,
workDir,
completedAt
}
go (receipt : completed) remaining
ExitFailure exitCode ->
pure
( Left
CommandExecutionError
{ command = planned,
exitCode,
stdout,
stderr
}
)
_ -> go completed remaining
-- | 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 =
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