packages feed

seihou-okf-extension-0.4.0.0: test/Seihou/OKF/Docs/ModelSpec.hs

module Seihou.OKF.Docs.ModelSpec (tests) where

import Data.List (find)
import Data.Maybe (fromMaybe)
import Data.Text qualified as T
import Seihou.OKF.Docs.Model
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.OKF.Docs.Model" spec

spec :: Spec
spec = do
  describe "loadDocModel" $ do
    it "loads all four registry entry kinds from a fixture registry" $ do
      withFixtureRegistry $ \registryDir -> do
        model <- shouldLoad registryDir
        model.docRepoName `shouldBe` "fixture-registry"
        length (entriesByKind DocModuleKind model) `shouldBe` 3
        length (entriesByKind DocRecipeKind model) `shouldBe` 1
        length (entriesByKind DocBlueprintKind model) `shouldBe` 1
        length (entriesByKind DocPromptKind model) `shouldBe` 1

    it "keeps catalog metadata from the registry entry" $ do
      withFixtureRegistry $ \registryDir -> do
        model <- shouldLoad registryDir
        let entry = requireEntry "app" model
        entry.entryVersion `shouldBe` Just "1.2.3"
        entry.entryDescription `shouldBe` Just "Application module"
        entry.entryTags `shouldBe` ["haskell", "app"]
        entry.entryPath `shouldBe` "modules/app"

    it "marks module dependencies that resolve inside the registry" $ do
      withFixtureRegistry $ \registryDir -> do
        model <- shouldLoad registryDir
        let entry = requireEntry "app" model
        entry.entryModuleRefs `shouldContain` [ModuleRef "base" True]

    it "marks module dependencies that do not resolve inside the registry" $ do
      withFixtureRegistry $ \registryDir -> do
        model <- shouldLoad registryDir
        let entry = requireEntry "dangling" model
        entry.entryModuleRefs `shouldBe` [ModuleRef "missing" False]

    it "captures recipe and blueprint module references" $ do
      withFixtureRegistry $ \registryDir -> do
        model <- shouldLoad registryDir
        let recipe = requireEntry "app-recipe" model
            blueprint = requireEntry "app-blueprint" model
        recipe.entryModuleRefs `shouldMatchList` [ModuleRef "base" True, ModuleRef "app" True]
        blueprint.entryModuleRefs `shouldBe` [ModuleRef "base" True]

    it "returns RegistryNotFound when the registry file is absent" $ do
      withSystemTempDirectory "seihou-doc-model-missing" $ \registryDir -> do
        result <- loadDocModel registryDir
        result `shouldBe` Left (RegistryNotFound (registryDir </> "seihou-registry.dhall"))

shouldLoad :: FilePath -> IO DocModel
shouldLoad registryDir = do
  result <- loadDocModel registryDir
  case result of
    Left err -> do
      expectationFailure ("Expected Right, got Left: " <> show err)
      error "unreachable"
    Right model -> pure model

entriesByKind :: DocKind -> DocModel -> [DocEntry]
entriesByKind kind model =
  filter (\entry -> entry.entryKind == kind) model.docEntries

requireEntry :: String -> DocModel -> DocEntry
requireEntry name model =
  fromMaybe (error ("missing entry " <> name)) $
    find (\entry -> entry.entryName == T.pack name) model.docEntries

withFixtureRegistry :: (FilePath -> IO a) -> IO a
withFixtureRegistry action =
  withSystemTempDirectory "seihou-doc-model" $ \registryDir -> do
    writeFixtureRegistry registryDir
    action registryDir

writeFixtureRegistry :: FilePath -> IO ()
writeFixtureRegistry registryDir = do
  writeFile (registryDir </> "seihou-registry.dhall") registryDhall
  writeModule registryDir "modules/base" "base" [] "Base module"
  writeModule registryDir "modules/app" "app" ["base"] "Application module"
  writeModule registryDir "modules/dangling" "dangling" ["missing"] "Dangling module"
  writeRecipe registryDir
  writeBlueprint registryDir
  writePrompt registryDir

writeModule :: FilePath -> FilePath -> String -> [String] -> String -> IO ()
writeModule registryDir relDir name dependencies description = do
  createDirectoryIfMissing True (registryDir </> relDir)
  writeFile (registryDir </> relDir </> "module.dhall") (moduleDhall name dependencies description)

writeRecipe :: FilePath -> IO ()
writeRecipe registryDir = do
  let relDir = "recipes/app-recipe"
  createDirectoryIfMissing True (registryDir </> relDir)
  writeFile (registryDir </> relDir </> "recipe.dhall") recipeDhall

writeBlueprint :: FilePath -> IO ()
writeBlueprint registryDir = do
  let relDir = "blueprints/app-blueprint"
  createDirectoryIfMissing True (registryDir </> relDir)
  writeFile (registryDir </> relDir </> "blueprint.dhall") blueprintDhall

writePrompt :: FilePath -> IO ()
writePrompt registryDir = do
  let relDir = "prompts/review"
  createDirectoryIfMissing True (registryDir </> relDir)
  writeFile (registryDir </> relDir </> "prompt.dhall") promptDhall

registryDhall :: String
registryDhall =
  "{ repoName = \"fixture-registry\"\n\
  \, repoDescription = Some \"Fixture registry\"\n\
  \, modules =\n\
  \  [ { name = \"base\", version = Some \"1.0.0\", path = \"modules/base\", description = Some \"Base module\", tags = [ \"haskell\" ] }\n\
  \  , { name = \"app\", version = Some \"1.2.3\", path = \"modules/app\", description = Some \"Application module\", tags = [ \"haskell\", \"app\" ] }\n\
  \  , { name = \"dangling\", version = None Text, path = \"modules/dangling\", description = Some \"Dangling module\", tags = [] : List Text }\n\
  \  ]\n\
  \, recipes = [ { name = \"app-recipe\", version = Some \"0.1.0\", path = \"recipes/app-recipe\", description = Some \"Recipe\", tags = [ \"recipe\" ] } ]\n\
  \, blueprints = [ { name = \"app-blueprint\", version = Some \"0.1.0\", path = \"blueprints/app-blueprint\", description = Some \"Blueprint\", tags = [ \"blueprint\" ] } ]\n\
  \, prompts = [ { name = \"review\", version = Some \"0.1.0\", path = \"prompts/review\", description = Some \"Review prompt\", tags = [ \"prompt\" ] } ]\n\
  \}"

moduleDhall :: String -> [String] -> String -> String
moduleDhall name dependencies description =
  "{ name = \""
    <> name
    <> "\"\n\
       \, version = Some \"1.0.0\"\n\
       \, description = Some \""
    <> description
    <> "\"\n\
       \, vars = [] : "
    <> varDeclListType
    <> "\n\
       \, exports = [] : "
    <> varExportListType
    <> "\n\
       \, prompts = [] : "
    <> promptListType
    <> "\n\
       \, steps = [] : "
    <> stepListType
    <> "\n\
       \, commands = [] : "
    <> commandListType
    <> "\n\
       \, dependencies = "
    <> dependencyList dependencies
    <> "\n\
       \}"

recipeDhall :: String
recipeDhall =
  "{ name = \"app-recipe\"\n\
  \, version = Some \"0.1.0\"\n\
  \, description = Some \"Recipe\"\n\
  \, modules = [ \"base\", \"app\" ]\n\
  \, vars = [] : "
    <> varDeclListType
    <> "\n\
       \, prompts = [] : "
    <> promptListType
    <> "\n\
       \}"

blueprintDhall :: String
blueprintDhall =
  "{ name = \"app-blueprint\"\n\
  \, version = Some \"0.1.0\"\n\
  \, description = Some \"Blueprint\"\n\
  \, prompt = \"Build the app\"\n\
  \, vars = [] : "
    <> varDeclListType
    <> "\n\
       \, prompts = [] : "
    <> promptListType
    <> "\n\
       \, baseModules = [ \"base\" ]\n\
       \, files = [] : "
    <> blueprintFileListType
    <> "\n\
       \, allowedTools = None (List Text)\n\
       \, tags = [ \"blueprint\" ]\n\
       \}"

promptDhall :: String
promptDhall =
  "{ name = \"review\"\n\
  \, version = Some \"0.1.0\"\n\
  \, description = Some \"Review prompt\"\n\
  \, prompt = \"Review the change\"\n\
  \, vars = [] : "
    <> varDeclListType
    <> "\n\
       \, prompts = [] : "
    <> promptListType
    <> "\n\
       \, commandVars = [] : "
    <> commandVarListType
    <> "\n\
       \, files = [] : "
    <> blueprintFileListType
    <> "\n\
       \, allowedTools = None (List Text)\n\
       \, tags = [ \"prompt\" ]\n\
       \, launch = None { provider : Optional Text, mode : Optional Text, model : Optional Text }\n\
       \}"

dependencyList :: [String] -> String
dependencyList [] = "[] : List Text"
dependencyList deps = "[ " <> joinComma (map show deps) <> " ]"

joinComma :: [String] -> String
joinComma [] = ""
joinComma [x] = x
joinComma (x : xs) = x <> ", " <> joinComma xs

varDeclListType :: String
varDeclListType =
  "List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }"

varExportListType :: String
varExportListType =
  "List { var : Text, alias : Optional Text }"

promptListType :: String
promptListType =
  "List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }"

stepListType :: String
stepListType =
  "List { strategy : Text, src : Text, dest : Text, when : Optional Text, patch : Optional Text }"

commandListType :: String
commandListType =
  "List { run : Text, workDir : Optional Text, when : Optional Text }"

blueprintFileListType :: String
blueprintFileListType =
  "List { src : Text, description : Optional Text }"

commandVarListType :: String
commandVarListType =
  "List { name : Text, run : Text, workDir : Optional Text, when : Optional Text, trim : Bool, maxBytes : Optional Natural }"