packages feed

seihou-core-0.4.0.0: src/Seihou/Core/CommandVar.hs

module Seihou.Core.CommandVar
  ( resolveCommandVars,
    planCommandVars,
    commandVarDecl,
  )
where

import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Seihou.Core.Expr (evalExpr)
import Seihou.Core.Path (validateProjectRelativePath)
import Seihou.Core.Types
import Seihou.Core.Variable (coerceValue, validateVarValue)
import Seihou.Effect.Process (Process, runProcess)
import Seihou.Prelude
import System.Exit (ExitCode (..))

-- | Select command variables that should run. Already-resolved variables keep
-- their existing value; command variables only fill gaps.
planCommandVars :: [CommandVar] -> Map VarName ResolvedVar -> Map VarName VarValue -> [CommandVar]
planCommandVars commandVars existing bindings =
  filter shouldRun commandVars
  where
    conditionBindings = resolvedValues existing <> bindings

    shouldRun cv =
      not (Map.member cv.name existing)
        && maybe True (evalExpr conditionBindings) cv.condition

-- | Return the matching declaration for a command variable, or synthesize a
-- text declaration for prompt-only dynamic context such as @git.branch@.
commandVarDecl :: [VarDecl] -> CommandVar -> VarDecl
commandVarDecl decls cv =
  case filter (\decl -> decl.name == cv.name) decls of
    decl : _ -> decl
    [] ->
      VarDecl
        { name = cv.name,
          type_ = VTText,
          default_ = Nothing,
          description = Nothing,
          required = False,
          validation = Nothing
        }

-- | Resolve command-derived variables through the process effect.
--
-- Existing resolved values have higher precedence and are never overwritten.
-- Command results are accumulated in order so later @when@ expressions can
-- depend on earlier command-derived values.
resolveCommandVars ::
  (Process :> es) =>
  [VarDecl] ->
  [CommandVar] ->
  Map VarName ResolvedVar ->
  Eff es (Either [VarError] (Map VarName ResolvedVar))
resolveCommandVars decls commandVars existing = do
  (errs, resolved) <- go [] existing (resolvedValues existing) commandVars
  pure $
    if null errs
      then Right resolved
      else Left errs
  where
    go errs resolved _bindings [] = pure (reverse errs, resolved)
    go errs resolved bindings (cv : rest)
      | Map.member cv.name resolved = go errs resolved bindings rest
      | maybe False (not . evalExpr bindings) cv.condition = go errs resolved bindings rest
      | otherwise = do
          result <- resolveOne bindings cv
          case result of
            Left err -> go (err : errs) resolved bindings rest
            Right rv ->
              let resolved' = Map.insert cv.name rv resolved
                  bindings' = Map.insert cv.name rv.value bindings
               in go errs resolved' bindings' rest

    resolveOne _bindings cv = do
      let decl = commandVarDecl decls cv
      case validateWorkDir cv of
        Left err -> pure (Left err)
        Right workDir -> do
          (exitCode, stdoutText, stderrText) <- runProcess "sh" ["-c", cv.run] workDir
          pure $ case exitCode of
            ExitSuccess -> coerceCommandOutput decl cv stdoutText
            ExitFailure code ->
              Left $
                ValidationFailed
                  cv.name
                  ( "command failed with exit code "
                      <> T.pack (show code)
                      <> ": "
                      <> summarizeDiagnostic stderrText
                  )

    validateWorkDir :: CommandVar -> Either VarError (Maybe FilePath)
    validateWorkDir cv@CommandVar {workDir = Nothing} = Right Nothing
    validateWorkDir cv@CommandVar {workDir = Just wd} =
      case validateProjectRelativePath wd of
        Left err -> Left (ValidationFailed cv.name ("command variable workDir " <> err))
        Right _ -> Right (Just (T.unpack wd))

coerceCommandOutput :: VarDecl -> CommandVar -> Text -> Either VarError ResolvedVar
coerceCommandOutput decl cv stdoutText = do
  let output =
        if cv.trim
          then T.strip stdoutText
          else stdoutText
  case cv.maxBytes of
    Just n
      | fromIntegral (T.length output) > n ->
          Left (ValidationFailed cv.name ("command output exceeds maxBytes " <> T.pack (show n)))
    _ -> do
      value <- coerceValue decl.name decl.type_ output
      validateVarValue decl value
      Right
        ResolvedVar
          { value = value,
            source = FromCommand cv.run,
            decl = decl
          }

resolvedValues :: Map VarName ResolvedVar -> Map VarName VarValue
resolvedValues = Map.map (.value)

summarizeDiagnostic :: Text -> Text
summarizeDiagnostic t =
  let stripped = T.strip t
   in if T.null stripped
        then "no stderr"
        else T.take 200 stripped