packages feed

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

module Seihou.Core.ModuleSpec (tests) where

import Data.Text qualified as T
import Seihou.Core.Module (discoverModule, loadModule, validateModule)
import Seihou.Core.Types
import System.Directory (createDirectoryIfMissing, getCurrentDirectory)
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.Core.Module" spec

-- | A well-formed module for pure validation tests.
goodModule :: Module
goodModule =
  Module
    { name = "test-module",
      version = Just "1.0.0",
      description = Just "A test module",
      vars =
        [ VarDecl
            { name = "project.name",
              type_ = VTText,
              default_ = Nothing,
              description = Just "Project name",
              required = True,
              validation = Nothing
            }
        ],
      exports = [VarExport {var = "project.name", alias = Nothing}],
      prompts = [Prompt {var = "project.name", text = "Name?", condition = Nothing, choices = Nothing}],
      steps =
        [ Step
            { strategy = Template,
              src = "README.md.tpl",
              dest = "README.md",
              condition = Nothing,
              patch = Nothing
            }
        ],
      commands = [],
      dependencies = [],
      removal = Nothing,
      migrations = []
    }

-- | Helpers to update Module fields without ambiguous record updates.
withModuleName :: ModuleName -> Module -> Module
withModuleName n m = Module n m.version m.description m.vars m.exports m.prompts m.steps m.commands m.dependencies m.removal m.migrations

withModuleVars :: [VarDecl] -> Module -> Module
withModuleVars v m = Module m.name m.version m.description v m.exports m.prompts m.steps m.commands m.dependencies m.removal m.migrations

withModulePrompts :: [Prompt] -> Module -> Module
withModulePrompts p m = Module m.name m.version m.description m.vars m.exports p m.steps m.commands m.dependencies m.removal m.migrations

hasError :: T.Text -> [T.Text] -> Bool
hasError needle = any (T.isInfixOf needle)

spec :: Spec
spec = do
  describe "discoverModule" $ do
    it "finds a module in the search path" $ do
      cwd <- getCurrentDirectory
      let searchPaths = [cwd </> "test" </> "fixtures"]
      result <- discoverModule searchPaths "haskell-base"
      case result of
        Right path -> path `shouldBe` (cwd </> "test" </> "fixtures" </> "haskell-base")
        Left err -> expectationFailure ("Expected Right, got Left: " <> show err)

    it "returns ModuleNotFound when module does not exist" $ do
      result <- discoverModule ["/nonexistent/path"] "no-such-module"
      case result of
        Left (ModuleNotFound name paths) -> do
          name.unModuleName `shouldBe` "no-such-module"
          paths `shouldBe` ["/nonexistent/path"]
        Left other -> expectationFailure ("Expected ModuleNotFound, got: " <> show other)
        Right _ -> expectationFailure "Expected Left, got Right"

    it "searches paths in order and returns the first match" $ do
      withSystemTempDirectory "seihou-test" $ \tmpDir -> do
        let dir1 = tmpDir </> "first"
        let dir2 = tmpDir </> "second"
        createDirectoryIfMissing True (dir1 </> "my-mod")
        createDirectoryIfMissing True (dir2 </> "my-mod")
        writeFile (dir1 </> "my-mod" </> "module.dhall") "{ name = \"my-mod\" }"
        writeFile (dir2 </> "my-mod" </> "module.dhall") "{ name = \"my-mod\" }"
        result <- discoverModule [dir1, dir2] "my-mod"
        case result of
          Right path -> path `shouldBe` (dir1 </> "my-mod")
          Left err -> expectationFailure ("Expected Right, got: " <> show err)

  describe "validateModule" $ do
    it "passes a well-formed module" $ do
      withSystemTempDirectory "seihou-test" $ \tmpDir -> do
        createDirectoryIfMissing True (tmpDir </> "files")
        writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
        result <- validateModule tmpDir goodModule
        case result of
          Right m -> m.name `shouldBe` "test-module"
          Left err -> expectationFailure ("Expected Right, got: " <> show err)

    it "rejects a bad module name" $ do
      withSystemTempDirectory "seihou-test" $ \tmpDir -> do
        createDirectoryIfMissing True (tmpDir </> "files")
        writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
        let bad = withModuleName "BadName" goodModule
        result <- validateModule tmpDir bad
        case result of
          Left (ValidationError _ errs) ->
            hasError "module name must match" errs `shouldBe` True
          Left other -> expectationFailure ("Expected ValidationError, got: " <> show other)
          Right _ -> expectationFailure "Expected validation failure"

    it "rejects duplicate variable names" $ do
      withSystemTempDirectory "seihou-test" $ \tmpDir -> do
        createDirectoryIfMissing True (tmpDir </> "files")
        writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
        let dup =
              withModuleVars
                [ VarDecl "x" VTText Nothing Nothing True Nothing,
                  VarDecl "x" VTBool Nothing Nothing False Nothing
                ]
                goodModule
        result <- validateModule tmpDir dup
        case result of
          Left (ValidationError _ errs) ->
            hasError "duplicate variable name" errs `shouldBe` True
          Left other -> expectationFailure ("Expected ValidationError, got: " <> show other)
          Right _ -> expectationFailure "Expected validation failure"

    it "rejects prompt referencing undeclared variable" $ do
      withSystemTempDirectory "seihou-test" $ \tmpDir -> do
        createDirectoryIfMissing True (tmpDir </> "files")
        writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
        let bad =
              withModulePrompts
                [Prompt {var = "nonexistent", text = "?", condition = Nothing, choices = Nothing}]
                goodModule
        result <- validateModule tmpDir bad
        case result of
          Left (ValidationError _ errs) ->
            hasError "prompt references undeclared" errs `shouldBe` True
          Left other -> expectationFailure ("Expected ValidationError, got: " <> show other)
          Right _ -> expectationFailure "Expected validation failure"

    it "rejects missing source file" $ do
      withSystemTempDirectory "seihou-test" $ \tmpDir -> do
        result <- validateModule tmpDir goodModule
        case result of
          Left (ValidationError _ errs) ->
            hasError "step source file not found" errs `shouldBe` True
          Left other -> expectationFailure ("Expected ValidationError, got: " <> show other)
          Right _ -> expectationFailure "Expected validation failure"

    it "rejects export referencing undeclared variable" $ do
      withSystemTempDirectory "seihou-test" $ \tmpDir -> do
        createDirectoryIfMissing True (tmpDir </> "files")
        writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
        let bad =
              goodModule
                { exports = [VarExport {var = "nonexistent", alias = Nothing}]
                }
        result <- validateModule tmpDir bad
        case result of
          Left (ValidationError _ errs) ->
            hasError "export references undeclared" errs `shouldBe` True
          Left other -> expectationFailure ("Expected ValidationError, got: " <> show other)
          Right _ -> expectationFailure "Expected validation failure"

    it "rejects unsafe destination path with .." $ do
      withSystemTempDirectory "seihou-test" $ \tmpDir -> do
        createDirectoryIfMissing True (tmpDir </> "files")
        writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
        let bad =
              goodModule
                { steps =
                    [Step Template "README.md.tpl" "../etc/passwd" Nothing Nothing]
                }
        result <- validateModule tmpDir bad
        case result of
          Left (ValidationError _ errs) ->
            hasError "must not contain '..'" errs `shouldBe` True
          Left other -> expectationFailure ("Expected ValidationError, got: " <> show other)
          Right _ -> expectationFailure "Expected validation failure"

    it "rejects absolute destination path" $ do
      withSystemTempDirectory "seihou-test" $ \tmpDir -> do
        createDirectoryIfMissing True (tmpDir </> "files")
        writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
        let bad =
              goodModule
                { steps =
                    [Step Template "README.md.tpl" "/etc/passwd" Nothing Nothing]
                }
        result <- validateModule tmpDir bad
        case result of
          Left (ValidationError _ errs) ->
            hasError "must be relative" errs `shouldBe` True
          Left other -> expectationFailure ("Expected ValidationError, got: " <> show other)
          Right _ -> expectationFailure "Expected validation failure"

    it "allows dots inside destination filenames" $ do
      withSystemTempDirectory "seihou-test" $ \tmpDir -> do
        createDirectoryIfMissing True (tmpDir </> "files")
        writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
        let dotted =
              goodModule
                { steps =
                    [Step Template "README.md.tpl" "docs/README.v2.md" Nothing Nothing]
                }
        result <- validateModule tmpDir dotted
        case result of
          Right m -> m.name `shouldBe` "test-module"
          Left err -> expectationFailure ("Expected Right, got: " <> show err)

    it "rejects destination referencing undeclared variable" $ do
      withSystemTempDirectory "seihou-test" $ \tmpDir -> do
        createDirectoryIfMissing True (tmpDir </> "files")
        writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
        let bad =
              goodModule
                { steps =
                    [Step Template "README.md.tpl" "src/{{unknown}}/Main.hs" Nothing Nothing]
                }
        result <- validateModule tmpDir bad
        case result of
          Left (ValidationError _ errs) ->
            hasError "destination references undeclared" errs `shouldBe` True
          Left other -> expectationFailure ("Expected ValidationError, got: " <> show other)
          Right _ -> expectationFailure "Expected validation failure"

    it "collects multiple errors at once" $ do
      withSystemTempDirectory "seihou-test" $ \tmpDir -> do
        let bad =
              Module
                { name = "BadName",
                  version = Nothing,
                  description = Nothing,
                  vars = [],
                  exports = [VarExport {var = "missing", alias = Nothing}],
                  prompts = [Prompt {var = "missing", text = "?", condition = Nothing, choices = Nothing}],
                  steps = [Step Template "nonexistent.tpl" "/bad/dest" Nothing Nothing],
                  commands = [],
                  dependencies = [],
                  removal = Nothing,
                  migrations = []
                }
        result <- validateModule tmpDir bad
        case result of
          Left (ValidationError _ errs) ->
            length errs `shouldSatisfy` (>= 4)
          Left other -> expectationFailure ("Expected ValidationError, got: " <> show other)
          Right _ -> expectationFailure "Expected validation failure"

  describe "loadModule" $ do
    it "loads the haskell-base fixture end-to-end" $ do
      cwd <- getCurrentDirectory
      let searchPaths = [cwd </> "test" </> "fixtures"]
      result <- loadModule searchPaths "haskell-base"
      case result of
        Right m -> do
          m.name `shouldBe` "haskell-base"
          length (m.vars) `shouldBe` 3
          length (m.steps) `shouldBe` 5
        Left err -> expectationFailure ("Expected Right, got Left: " <> show err)

    it "returns ModuleNotFound for nonexistent module" $ do
      result <- loadModule ["/nonexistent"] "no-such-module"
      case result of
        Left (ModuleNotFound _ _) -> pure ()
        Left other -> expectationFailure ("Expected ModuleNotFound, got: " <> show other)
        Right _ -> expectationFailure "Expected Left, got Right"