packages feed

seihou-core-0.4.0.0: test/Seihou/Effect/ConfigReaderSpec.hs

module Seihou.Effect.ConfigReaderSpec (tests) where

import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Effectful
import Seihou.Core.Types (ConfigError (..))
import Seihou.Dhall.Config (evalConfigFileIfExists)
import Seihou.Effect.ConfigReader (readContextConfig, readGlobalConfig, readLocalConfig, readNamespaceConfig)
import Seihou.Effect.ConfigReaderInterp (runConfigReader)
import Seihou.Effect.ConfigReaderPure (runConfigReaderPure)
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "Seihou.Effect.ConfigReader" spec

spec :: Spec
spec = do
  describe "pure interpreter" $ do
    it "ReadGlobalConfig returns the scripted global map" $ do
      let globalCfg = Map.fromList [("license", "MIT"), ("author", "Test")]
          result = runPureEff $ runConfigReaderPure Map.empty Map.empty Map.empty globalCfg readGlobalConfig
      result `shouldBe` Right globalCfg

    it "ReadLocalConfig returns the scripted local map" $ do
      let localCfg = Map.fromList [("project.name", "local-app")]
          result = runPureEff $ runConfigReaderPure localCfg Map.empty Map.empty Map.empty readLocalConfig
      result `shouldBe` Right localCfg

    it "ReadNamespaceConfig returns the scripted namespace map" $ do
      let nsCfgs = Map.fromList [("haskell", Map.fromList [("haskell.ghc", "9.12.2")])]
          result = runPureEff $ runConfigReaderPure Map.empty nsCfgs Map.empty Map.empty (readNamespaceConfig "haskell")
      result `shouldBe` Right (Map.fromList [("haskell.ghc", "9.12.2")])

    it "ReadNamespaceConfig returns empty for unknown namespace" $ do
      let nsCfgs = Map.fromList [("haskell", Map.fromList [("haskell.ghc", "9.12.2")])]
          result = runPureEff $ runConfigReaderPure Map.empty nsCfgs Map.empty Map.empty (readNamespaceConfig "nix")
      result `shouldBe` Right Map.empty

    it "ReadContextConfig returns the scripted context map" $ do
      let ctxCfgs = Map.fromList [("work", Map.fromList [("user.email", "work@example.com")])]
          result = runPureEff $ runConfigReaderPure Map.empty Map.empty ctxCfgs Map.empty (readContextConfig "work")
      result `shouldBe` Right (Map.fromList [("user.email", "work@example.com")])

    it "ReadContextConfig returns empty for unknown context" $ do
      let ctxCfgs = Map.fromList [("work", Map.fromList [("user.email", "work@example.com")])]
          result = runPureEff $ runConfigReaderPure Map.empty Map.empty ctxCfgs Map.empty (readContextConfig "personal")
      result `shouldBe` Right Map.empty

    it "returns independent values from each config layer" $ do
      let localCfg = Map.fromList [("project.name", "local-app")]
          nsCfgs = Map.fromList [("haskell", Map.fromList [("haskell.ghc", "9.12.2")])]
          ctxCfgs = Map.fromList [("work", Map.fromList [("user.email", "work@example.com")])]
          globalCfg = Map.fromList [("license", "MIT")]
          (local, ns, ctx, global) = runPureEff $ runConfigReaderPure localCfg nsCfgs ctxCfgs globalCfg $ do
            l <- readLocalConfig
            n <- readNamespaceConfig "haskell"
            c <- readContextConfig "work"
            g <- readGlobalConfig
            pure (l, n, c, g)
      local `shouldBe` Right localCfg
      ns `shouldBe` Right (Map.fromList [("haskell.ghc", "9.12.2")])
      ctx `shouldBe` Right (Map.fromList [("user.email", "work@example.com")])
      global `shouldBe` Right globalCfg

  describe "namespace validation (IO interpreter)" $ do
    it "rejects namespace containing '..'" $ do
      result <- runEff $ runConfigReader $ readNamespaceConfig "../etc"
      case result of
        Left (InvalidNamespace ns _) -> ns `shouldBe` "../etc"
        Left other -> expectationFailure ("Expected InvalidNamespace, got: " <> show other)
        Right _ -> expectationFailure "Expected Left for path traversal namespace"

    it "rejects namespace containing '/'" $ do
      result <- runEff $ runConfigReader $ readNamespaceConfig "foo/bar"
      case result of
        Left (InvalidNamespace ns _) -> ns `shouldBe` "foo/bar"
        Left other -> expectationFailure ("Expected InvalidNamespace, got: " <> show other)
        Right _ -> expectationFailure "Expected Left for namespace with slash"

    it "accepts a normal namespace" $ do
      result <- runEff $ runConfigReader $ readNamespaceConfig "haskell"
      case result of
        Left (InvalidNamespace _ _) -> expectationFailure "Did not expect InvalidNamespace for 'haskell'"
        _ -> pure () -- Right or ConfigParseError (missing file) are both OK
    it "returns Right empty for empty namespace" $ do
      result <- runEff $ runConfigReader $ readNamespaceConfig ""
      result `shouldBe` Right Map.empty

  describe "context validation (IO interpreter)" $ do
    it "rejects context containing '..'" $ do
      result <- runEff $ runConfigReader $ readContextConfig "../etc"
      case result of
        Left (InvalidNamespace ctx _) -> ctx `shouldBe` "../etc"
        Left other -> expectationFailure ("Expected InvalidNamespace, got: " <> show other)
        Right _ -> expectationFailure "Expected Left for path traversal context"

    it "rejects context containing '/'" $ do
      result <- runEff $ runConfigReader $ readContextConfig "foo/bar"
      case result of
        Left (InvalidNamespace ctx _) -> ctx `shouldBe` "foo/bar"
        Left other -> expectationFailure ("Expected InvalidNamespace, got: " <> show other)
        Right _ -> expectationFailure "Expected Left for context with slash"

    it "accepts a normal context" $ do
      result <- runEff $ runConfigReader $ readContextConfig "work"
      case result of
        Left (InvalidNamespace _ _) -> expectationFailure "Did not expect InvalidNamespace for 'work'"
        _ -> pure ()

    it "returns Right empty for empty context" $ do
      result <- runEff $ runConfigReader $ readContextConfig ""
      result `shouldBe` Right Map.empty

  describe "config parse error propagation" $ do
    it "evalConfigFileIfExists returns Left for malformed Dhall" $ do
      withSystemTempDirectory "seihou-test" $ \tmpDir -> do
        let path = tmpDir <> "/bad.dhall"
        writeFile path "{ this is not valid dhall"
        result <- evalConfigFileIfExists path
        case result of
          Left err -> T.isInfixOf "Error reading config" err `shouldBe` True
          Right _ -> expectationFailure "Expected Left for malformed Dhall"