packages feed

seihou-core-0.4.0.0: test/Seihou/Core/CommandVarSpec.hs

module Seihou.Core.CommandVarSpec (tests) where

import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Effectful (runPureEff)
import Numeric.Natural (Natural)
import Seihou.Core.CommandVar (planCommandVars, resolveCommandVars)
import Seihou.Core.Types
import Seihou.Effect.ProcessPure (ProcessMock (..), runProcessPure)
import System.Exit (ExitCode (..))
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "Seihou.Core.CommandVar" spec

textDecl :: VarName -> VarDecl
textDecl name = VarDecl name VTText Nothing Nothing False Nothing

boolDecl :: VarName -> VarDecl
boolDecl name = VarDecl name VTBool Nothing Nothing False Nothing

intDecl :: VarName -> VarDecl
intDecl name = VarDecl name VTInt Nothing Nothing False Nothing

patternDecl :: VarName -> VarDecl
patternDecl name = VarDecl name VTText Nothing Nothing False (Just (ValPattern "[a-z][a-z0-9-]*"))

cmdVar :: VarName -> T.Text -> CommandVar
cmdVar name run =
  CommandVar
    { name = name,
      run = run,
      workDir = Nothing,
      condition = Nothing,
      trim = True,
      maxBytes = Just 4096
    }

withCondition :: Maybe Expr -> CommandVar -> CommandVar
withCondition condition cv =
  CommandVar cv.name cv.run cv.workDir condition cv.trim cv.maxBytes

withTrim :: Bool -> CommandVar -> CommandVar
withTrim trim cv =
  CommandVar cv.name cv.run cv.workDir cv.condition trim cv.maxBytes

withMaxBytes :: Maybe Natural -> CommandVar -> CommandVar
withMaxBytes maxBytes cv =
  CommandVar cv.name cv.run cv.workDir cv.condition cv.trim maxBytes

commandVarName :: CommandVar -> VarName
commandVarName cv = cv.name

commandVarRun :: CommandVar -> T.Text
commandVarRun cv = cv.run

mock :: T.Text -> ExitCode -> T.Text -> T.Text -> ProcessMock
mock run exitCode stdoutText stderrText =
  ProcessMock
    { mockCommand = "sh",
      mockArgs = ["-c", run],
      mockResult = (exitCode, stdoutText, stderrText)
    }

runResolve ::
  [VarDecl] ->
  [CommandVar] ->
  Map.Map VarName ResolvedVar ->
  [ProcessMock] ->
  Either [VarError] (Map.Map VarName ResolvedVar)
runResolve decls commandVars existing mocks =
  runPureEff $
    runProcessPure mocks $
      resolveCommandVars decls commandVars existing

resolved :: VarDecl -> VarValue -> VarSource -> ResolvedVar
resolved decl value source = ResolvedVar {value = value, source = source, decl = decl}

spec :: Spec
spec = do
  describe "planCommandVars" $ do
    it "skips already-resolved variables and false conditions" $ do
      let branchDecl = textDecl "git.branch"
          readyDecl = boolDecl "release.ready"
          existing = Map.singleton "git.branch" (resolved branchDecl (VText "configured") FromLocalConfig)
          bindings = Map.singleton "release.ready" (VBool False)
          planned =
            planCommandVars
              [ cmdVar "git.branch" "git branch --show-current",
                withCondition (Just (ExprIsSet "git.branch")) (cmdVar "release.notes" "git log"),
                withCondition (Just (ExprEq "release.ready" (VBool True))) (cmdVar "release.ready" "echo true")
              ]
              existing
              bindings
      map commandVarName planned `shouldBe` ["release.notes"]

  describe "resolveCommandVars" $ do
    it "resolves text and bool values from command output" $ do
      let branch = cmdVar "git.branch" "git branch --show-current"
          ready = cmdVar "release.ready" "printf true"
          result =
            runResolve
              [textDecl "git.branch", boolDecl "release.ready"]
              [branch, ready]
              Map.empty
              [ mock (commandVarRun branch) ExitSuccess "main\n" "",
                mock (commandVarRun ready) ExitSuccess "true\n" ""
              ]
      case result of
        Right m -> do
          fmap (.value) (Map.lookup "git.branch" m) `shouldBe` Just (VText "main")
          fmap (.value) (Map.lookup "release.ready" m) `shouldBe` Just (VBool True)
          fmap (.source) (Map.lookup "git.branch" m) `shouldBe` Just (FromCommand "git branch --show-current")
        Left errs -> expectationFailure ("Expected Right, got: " <> show errs)

    it "does not override already-resolved config values" $ do
      let decl = textDecl "git.branch"
          existing = Map.singleton "git.branch" (resolved decl (VText "configured") FromLocalConfig)
          branch = cmdVar "git.branch" "git branch --show-current"
          result =
            runResolve
              [decl]
              [branch]
              existing
              [mock (commandVarRun branch) ExitSuccess "main\n" ""]
      result `shouldBe` Right existing

    it "coerces int output using the matching declaration" $ do
      let count = cmdVar "change.count" "git diff --numstat"
          result =
            runResolve
              [intDecl "change.count"]
              [count]
              Map.empty
              [mock (commandVarRun count) ExitSuccess "42\n" ""]
      fmap (fmap (.value) . Map.lookup "change.count") result `shouldBe` Right (Just (VInt 42))

    it "uses a text declaration for command-only prompt variables" $ do
      let branch = cmdVar "git.branch" "git branch --show-current"
          result =
            runResolve
              []
              [branch]
              Map.empty
              [mock (commandVarRun branch) ExitSuccess "main\n" ""]
      fmap (fmap (.value) . Map.lookup "git.branch") result `shouldBe` Right (Just (VText "main"))

    it "preserves untrimmed output when trim is false" $ do
      let branch = withTrim False (cmdVar "git.branch" "git branch --show-current")
          result =
            runResolve
              [textDecl "git.branch"]
              [branch]
              Map.empty
              [mock (commandVarRun branch) ExitSuccess "main\n" ""]
      fmap (fmap (.value) . Map.lookup "git.branch") result `shouldBe` Right (Just (VText "main\n"))

    it "rejects output that exceeds maxBytes" $ do
      let branch = withMaxBytes (Just 3) (cmdVar "git.branch" "git branch --show-current")
          result =
            runResolve
              [textDecl "git.branch"]
              [branch]
              Map.empty
              [mock (commandVarRun branch) ExitSuccess "main\n" ""]
      case result of
        Left [ValidationFailed "git.branch" msg] ->
          msg `shouldSatisfy` T.isInfixOf "exceeds maxBytes"
        other -> expectationFailure ("Expected maxBytes failure, got: " <> show other)

    it "reports non-zero command exits with stderr" $ do
      let branch = cmdVar "git.branch" "git branch --show-current"
          result =
            runResolve
              [textDecl "git.branch"]
              [branch]
              Map.empty
              [mock (commandVarRun branch) (ExitFailure 2) "" "not a git repo\n"]
      case result of
        Left [ValidationFailed "git.branch" msg] -> do
          msg `shouldSatisfy` T.isInfixOf "exit code 2"
          msg `shouldSatisfy` T.isInfixOf "not a git repo"
        other -> expectationFailure ("Expected command failure, got: " <> show other)

    it "skips commands with false conditions" $ do
      let branch =
            withCondition
              (Just (ExprEq "release.ready" (VBool True)))
              (cmdVar "git.branch" "git branch --show-current")
          result =
            runResolve
              [textDecl "git.branch"]
              [branch]
              (Map.singleton "release.ready" (resolved (boolDecl "release.ready") (VBool False) FromDefault))
              [mock (commandVarRun branch) ExitSuccess "main\n" ""]
      fmap (Map.member "git.branch") result `shouldBe` Right False

    it "validates coerced values against declaration validation" $ do
      let branch = cmdVar "git.branch" "git branch --show-current"
          result =
            runResolve
              [patternDecl "git.branch"]
              [branch]
              Map.empty
              [mock (commandVarRun branch) ExitSuccess "Bad_Branch\n" ""]
      case result of
        Left [ValidationFailed "git.branch" msg] ->
          msg `shouldSatisfy` T.isInfixOf "value does not match pattern"
        other -> expectationFailure ("Expected validation failure, got: " <> show other)