packages feed

seihou-core-0.6.0.0: test/Seihou/Dhall/EvalSpec.hs

module Seihou.Dhall.EvalSpec (tests) where

import Control.Lens ((^.))
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Seihou.Core.Types
import Seihou.Dhall.Eval (evalDhallExpr, evalModuleFromFile, evalRecipeFromFile)
import System.Directory (createDirectoryIfMissing)
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "Seihou.Dhall.Eval" spec

fixtureDir :: FilePath
fixtureDir = "test/fixtures"

-- | Build a minimal @module.dhall@ source containing a single variable with the
-- given name, declared type string, and @default@ Dhall expression (e.g.
-- @"Some \"true\""@ or @"None Text"@).
moduleWithVar :: String -> String -> String -> String
moduleWithVar varName varType defaultExpr =
  "{ name = \"var-test\"\n\
  \, version = None Text\n\
  \, description = None Text\n\
  \, vars =\n\
  \  [ { name = \""
    ++ varName
    ++ "\"\n\
       \    , type = \""
    ++ varType
    ++ "\"\n\
       \    , default = "
    ++ defaultExpr
    ++ "\n\
       \    , description = None Text\n\
       \    , required = False\n\
       \    , validation = None Text\n\
       \    }\n\
       \  ]\n\
       \, exports = [] : List { var : Text, alias : Optional Text }\n\
       \, prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }\n\
       \, steps = [] : List { strategy : Text, src : Text, dest : Text, when : Optional Text, patch : Optional Text }\n\
       \, commands = [] : List { run : Text, workDir : Optional Text, when : Optional Text }\n\
       \, dependencies = [] : List Text\n\
       \, removal = None { steps : List { action : Text, dest : Text, src : Optional Text }, commands : List { run : Text, workDir : Optional Text, when : Optional Text } }\n\
       \}"

spec :: Spec
spec = do
  describe "evalDhallExpr (spike)" $ do
    it "decodes a simple Dhall record with name and version" $ do
      result <- evalDhallExpr "{ name = \"my-project\", version = \"0.1.0\" }"
      Map.lookup "name" result `shouldBe` Just "my-project"
      Map.lookup "version" result `shouldBe` Just "0.1.0"

  describe "evalModuleFromFile" $ do
    it "decodes the haskell-base fixture module" $ do
      result <- evalModuleFromFile (fixtureDir </> "haskell-base" </> "module.dhall")
      case result of
        Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
        Right m -> do
          (m ^. #name) `shouldBe` ModuleName "haskell-base"
          (m ^. #description) `shouldBe` Just "A Haskell project template"
          length (m ^. #vars) `shouldBe` 3
          length (m ^. #prompts) `shouldBe` 1
          length (m ^. #steps) `shouldBe` 5
          length (m ^. #exports) `shouldBe` 1
          (m ^. #dependencies) `shouldBe` []

    it "decodes variable declarations correctly" $ do
      result <- evalModuleFromFile (fixtureDir </> "haskell-base" </> "module.dhall")
      case result of
        Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
        Right m -> do
          let (projectName : projectVersion : _) = (m ^. #vars)
          (projectName ^. #name) `shouldBe` VarName "project.name"
          (projectName ^. #type_) `shouldBe` VTText
          (projectName ^. #default_) `shouldBe` Nothing
          (projectName ^. #required) `shouldBe` True
          (projectName ^. #validation) `shouldBe` Just (ValPattern "[a-z][a-z0-9-]*")

          (projectVersion ^. #name) `shouldBe` VarName "project.version"
          (projectVersion ^. #default_) `shouldBe` Just (VText "0.1.0.0")
          (projectVersion ^. #required) `shouldBe` False

    it "decodes steps with correct strategy" $ do
      result <- evalModuleFromFile (fixtureDir </> "haskell-base" </> "module.dhall")
      case result of
        Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
        Right m -> do
          let (readme : libStep : licenseStep : _) = (m ^. #steps)
          (readme ^. #strategy) `shouldBe` Template
          (readme ^. #src) `shouldBe` "README.md.tpl"
          (readme ^. #dest) `shouldBe` "README.md"
          (readme ^. #condition) `shouldBe` Nothing
          (readme ^. #patch) `shouldBe` Nothing

          (libStep ^. #strategy) `shouldBe` Template
          (libStep ^. #src) `shouldBe` "src/Lib.hs.tpl"
          (libStep ^. #dest) `shouldBe` "src/Lib.hs"
          (libStep ^. #patch) `shouldBe` Nothing

          (licenseStep ^. #strategy) `shouldBe` Copy
          (licenseStep ^. #src) `shouldBe` "LICENSE"
          (licenseStep ^. #dest) `shouldBe` "LICENSE"
          (licenseStep ^. #condition) `shouldBe` Just (ExprIsSet "license")
          (licenseStep ^. #patch) `shouldBe` Nothing

    it "decodes exports correctly" $ do
      result <- evalModuleFromFile (fixtureDir </> "haskell-base" </> "module.dhall")
      case result of
        Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
        Right m -> do
          let (export1 : _) = (m ^. #exports)
          (export1 ^. #var) `shouldBe` VarName "project.name"
          (export1 ^. #alias) `shouldBe` Nothing

    it "returns DhallEvalError for nonexistent file" $ do
      result <- evalModuleFromFile "/nonexistent/path/module.dhall"
      case result of
        Left (DhallEvalError _ _) -> pure ()
        Left other -> expectationFailure ("Expected DhallEvalError, got: " <> show other)
        Right _ -> expectationFailure "Expected Left, got Right"

    it "returns Left for unknown var type (not a crash)" $ do
      result <- evalModuleFromFile (fixtureDir </> "bad-vartype" </> "module.dhall")
      case result of
        Left (DhallEvalError _ msg) ->
          T.isInfixOf "strng" msg `shouldBe` True
        Left other -> expectationFailure ("Expected DhallEvalError, got: " <> show other)
        Right _ -> expectationFailure "Expected Left for bad var type"

    it "returns Left for unknown strategy (not a crash)" $ do
      result <- evalModuleFromFile (fixtureDir </> "bad-strategy" </> "module.dhall")
      case result of
        Left (DhallEvalError _ msg) ->
          T.isInfixOf "coppy" msg `shouldBe` True
        Left other -> expectationFailure ("Expected DhallEvalError, got: " <> show other)
        Right _ -> expectationFailure "Expected Left for bad strategy"

    it "decodes prompts correctly" $ do
      result <- evalModuleFromFile (fixtureDir </> "haskell-base" </> "module.dhall")
      case result of
        Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
        Right m -> do
          let (prompt1 : _) = (m ^. #prompts)
          (prompt1 ^. #var) `shouldBe` VarName "project.name"
          (prompt1 ^. #text) `shouldBe` "What is the project name?"
          (prompt1 ^. #condition) `shouldBe` Nothing
          (prompt1 ^. #choices) `shouldBe` Nothing

    it "decodes step with patch = Some \"append-file\"" $ do
      withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do
        createDirectoryIfMissing True (tmpDir </> "files")
        writeFile (tmpDir </> "files" </> "section.tpl") "section content"
        let dhall =
              "{ name = \"patch-test\"\n\
              \, version = None Text\n\
              \, description = None Text\n\
              \, vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }\n\
              \, exports = [] : List { var : Text, alias : Optional Text }\n\
              \, prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }\n\
              \, steps =\n\
              \  [ { strategy = \"template\"\n\
              \    , src = \"section.tpl\"\n\
              \    , dest = \"README.md\"\n\
              \    , when = None Text\n\
              \    , patch = Some \"append-file\"\n\
              \    }\n\
              \  , { strategy = \"template\"\n\
              \    , src = \"section.tpl\"\n\
              \    , dest = \"README.md\"\n\
              \    , when = None Text\n\
              \    , patch = Some \"prepend-file\"\n\
              \    }\n\
              \  , { strategy = \"template\"\n\
              \    , src = \"section.tpl\"\n\
              \    , dest = \"README.md\"\n\
              \    , when = None Text\n\
              \    , patch = Some \"append-section\"\n\
              \    }\n\
              \  ]\n\
              \, commands = [] : List { run : Text, workDir : Optional Text, when : Optional Text }\n\
              \, dependencies = [] : List Text\n\
              \, removal = None { steps : List { action : Text, dest : Text, src : Optional Text }, commands : List { run : Text, workDir : Optional Text, when : Optional Text } }\n\
              \}"
        writeFile (tmpDir </> "module.dhall") dhall
        result <- evalModuleFromFile (tmpDir </> "module.dhall")
        case result of
          Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
          Right m -> do
            let (s1 : s2 : s3 : _) = (m ^. #steps)
            (s1 ^. #patch) `shouldBe` Just AppendFile
            (s2 ^. #patch) `shouldBe` Just PrependFile
            (s3 ^. #patch) `shouldBe` Just AppendSection

    it "returns Left for unknown patch operation (not a crash)" $ do
      withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do
        createDirectoryIfMissing True (tmpDir </> "files")
        writeFile (tmpDir </> "files" </> "section.tpl") "content"
        let dhall =
              "{ name = \"bad-patch\"\n\
              \, version = None Text\n\
              \, description = None Text\n\
              \, vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }\n\
              \, exports = [] : List { var : Text, alias : Optional Text }\n\
              \, prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }\n\
              \, steps =\n\
              \  [ { strategy = \"template\"\n\
              \    , src = \"section.tpl\"\n\
              \    , dest = \"README.md\"\n\
              \    , when = None Text\n\
              \    , patch = Some \"invalid-op\"\n\
              \    }\n\
              \  ]\n\
              \, commands = [] : List { run : Text, workDir : Optional Text, when : Optional Text }\n\
              \, dependencies = [] : List Text\n\
              \, removal = None { steps : List { action : Text, dest : Text, src : Optional Text }, commands : List { run : Text, workDir : Optional Text, when : Optional Text } }\n\
              \}"
        writeFile (tmpDir </> "module.dhall") dhall
        result <- evalModuleFromFile (tmpDir </> "module.dhall")
        case result of
          Left (DhallEvalError _ msg) ->
            T.isInfixOf "invalid-op" msg `shouldBe` True
          Left other -> expectationFailure ("Expected DhallEvalError, got: " <> show other)
          Right _ -> expectationFailure "Expected Left for bad patch operation"

    it "coerces a bool default to VBool at decode time" $ do
      withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do
        writeFile (tmpDir </> "module.dhall") (moduleWithVar "feature.on" "bool" "Some \"true\"")
        result <- evalModuleFromFile (tmpDir </> "module.dhall")
        case result of
          Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
          Right m -> do
            let v = head (m ^. #vars)
            (v ^. #type_) `shouldBe` VTBool
            (v ^. #default_) `shouldBe` Just (VBool True)

    it "coerces an int default to VInt at decode time" $ do
      withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do
        writeFile (tmpDir </> "module.dhall") (moduleWithVar "retries" "int" "Some \"3\"")
        result <- evalModuleFromFile (tmpDir </> "module.dhall")
        case result of
          Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
          Right m -> do
            let v = head (m ^. #vars)
            (v ^. #type_) `shouldBe` VTInt
            (v ^. #default_) `shouldBe` Just (VInt 3)

    it "fails module load on a malformed bool default" $ do
      withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do
        writeFile (tmpDir </> "module.dhall") (moduleWithVar "feature.on" "bool" "Some \"treu\"")
        result <- evalModuleFromFile (tmpDir </> "module.dhall")
        case result of
          Left (DhallEvalError _ msg) -> do
            T.isInfixOf "feature.on" msg `shouldBe` True
            T.isInfixOf "treu" msg `shouldBe` True
          Left other -> expectationFailure ("Expected DhallEvalError, got: " <> show other)
          Right _ -> expectationFailure "Expected Left for malformed bool default"

  describe "dependencyDecoder" $ do
    let emptyModuleWithDeps depsStr =
          "{ name = \"test-mod\"\n\
          \, version = None Text\n\
          \, description = None Text\n\
          \, vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }\n\
          \, exports = [] : List { var : Text, alias : Optional Text }\n\
          \, prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }\n\
          \, steps = [] : List { strategy : Text, src : Text, dest : Text, when : Optional Text, patch : Optional Text }\n\
          \, commands = [] : List { run : Text, workDir : Optional Text, when : Optional Text }\n\
          \, dependencies = "
            ++ depsStr
            ++ "\n\
               \, removal = None { steps : List { action : Text, dest : Text, src : Optional Text }, commands : List { run : Text, workDir : Optional Text, when : Optional Text } }\n\
               \}"

    it "decodes a bare string dependency" $ do
      withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do
        writeFile (tmpDir </> "module.dhall") (emptyModuleWithDeps "[\"base\"]")
        result <- evalModuleFromFile (tmpDir </> "module.dhall")
        case result of
          Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
          Right m -> do
            length (m ^. #dependencies) `shouldBe` 1
            let dep = head (m ^. #dependencies)
            (dep ^. #module_) `shouldBe` ModuleName "base"
            Map.null (dep ^. #vars) `shouldBe` True

    it "decodes a parameterized record dependency" $ do
      withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do
        writeFile (tmpDir </> "module.dhall") (emptyModuleWithDeps "[{ module = \"base\", vars = [{ name = \"x\", value = \"y\" }] }]")
        result <- evalModuleFromFile (tmpDir </> "module.dhall")
        case result of
          Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
          Right m -> do
            length (m ^. #dependencies) `shouldBe` 1
            let dep = head (m ^. #dependencies)
            (dep ^. #module_) `shouldBe` ModuleName "base"
            Map.lookup (VarName "x") (dep ^. #vars) `shouldBe` Just "y"

    it "decodes a parameterized dependency with empty vars" $ do
      withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do
        writeFile (tmpDir </> "module.dhall") (emptyModuleWithDeps "[{ module = \"base\", vars = [] : List { name : Text, value : Text } }]")
        result <- evalModuleFromFile (tmpDir </> "module.dhall")
        case result of
          Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
          Right m -> do
            length (m ^. #dependencies) `shouldBe` 1
            let dep = head (m ^. #dependencies)
            (dep ^. #module_) `shouldBe` ModuleName "base"
            Map.null (dep ^. #vars) `shouldBe` True

    it "decodes a module.dhall with parameterized dependencies" $ do
      withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do
        writeFile (tmpDir </> "module.dhall") (emptyModuleWithDeps "[{ module = \"child-mod\", vars = [{ name = \"skill.name\", value = \"exec-plan\" }] }]")
        result <- evalModuleFromFile (tmpDir </> "module.dhall")
        case result of
          Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
          Right m -> do
            length (m ^. #dependencies) `shouldBe` 1
            let dep = head (m ^. #dependencies)
            (dep ^. #module_) `shouldBe` ModuleName "child-mod"
            Map.lookup (VarName "skill.name") (dep ^. #vars) `shouldBe` Just "exec-plan"

  describe "evalRecipeFromFile" $ do
    it "decodes the haskell-with-nix-recipe fixture" $ do
      result <- evalRecipeFromFile (fixtureDir </> "haskell-with-nix-recipe" </> "recipe.dhall")
      case result of
        Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
        Right r -> do
          (r ^. #name) `shouldBe` RecipeName "haskell-with-nix"
          (r ^. #version) `shouldBe` Just "1.0.0"
          (r ^. #description) `shouldBe` Just "Haskell project with Nix integration"
          length (r ^. #modules) `shouldBe` 2
          let (m1 : m2 : _) = (r ^. #modules)
          (m1 ^. #module_) `shouldBe` ModuleName "haskell-base"
          Map.null (m1 ^. #vars) `shouldBe` True
          (m2 ^. #module_) `shouldBe` ModuleName "nix-flake"
          Map.null (m2 ^. #vars) `shouldBe` True
          (r ^. #vars) `shouldBe` []
          (r ^. #prompts) `shouldBe` []

    it "decodes the haskell-pinned-recipe fixture with variable bindings" $ do
      result <- evalRecipeFromFile (fixtureDir </> "haskell-pinned-recipe" </> "recipe.dhall")
      case result of
        Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
        Right r -> do
          (r ^. #name) `shouldBe` RecipeName "haskell-pinned"
          length (r ^. #modules) `shouldBe` 2
          let (m1 : m2 : _) = (r ^. #modules)
          (m1 ^. #module_) `shouldBe` ModuleName "haskell-base"
          Map.null (m1 ^. #vars) `shouldBe` True
          (m2 ^. #module_) `shouldBe` ModuleName "nix-flake"
          Map.lookup (VarName "nix.system") (m2 ^. #vars) `shouldBe` Just "aarch64-darwin"

    it "returns DhallEvalError for nonexistent recipe file" $ do
      result <- evalRecipeFromFile "/nonexistent/path/recipe.dhall"
      case result of
        Left (DhallEvalError _ _) -> pure ()
        Left other -> expectationFailure ("Expected DhallEvalError, got: " <> show other)
        Right _ -> expectationFailure "Expected Left, got Right"

    it "decodes a recipe with recipe-level vars and prompts" $ do
      withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do
        let dhall =
              "{ name = \"prompted-recipe\"\n\
              \, version = Some \"1.0.0\"\n\
              \, description = Some \"Recipe with prompts\"\n\
              \, modules =\n\
              \  [ { module = \"base\", vars = [] : List { name : Text, value : Text } }\n\
              \  ]\n\
              \, vars =\n\
              \  [ { name = \"project.name\"\n\
              \    , type = \"text\"\n\
              \    , default = None Text\n\
              \    , description = Some \"Project name\"\n\
              \    , required = True\n\
              \    , validation = None Text\n\
              \    }\n\
              \  ]\n\
              \, prompts =\n\
              \  [ { var = \"project.name\"\n\
              \    , text = \"What is the project name?\"\n\
              \    , when = None Text\n\
              \    , choices = None (List Text)\n\
              \    }\n\
              \  ]\n\
              \}"
        writeFile (tmpDir </> "recipe.dhall") dhall
        result <- evalRecipeFromFile (tmpDir </> "recipe.dhall")
        case result of
          Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
          Right r -> do
            (r ^. #name) `shouldBe` RecipeName "prompted-recipe"
            length (r ^. #vars) `shouldBe` 1
            let v = head (r ^. #vars)
            (v ^. #name) `shouldBe` VarName "project.name"
            (v ^. #type_) `shouldBe` VTText
            (v ^. #required) `shouldBe` True
            length (r ^. #prompts) `shouldBe` 1
            let p = head (r ^. #prompts)
            (p ^. #var) `shouldBe` VarName "project.name"
            (p ^. #text) `shouldBe` "What is the project name?"