packages feed

seihou-okf-extension-0.8.0.0: test/Seihou/OKF/Extension/DocsSpec.hs

module Seihou.OKF.Extension.DocsSpec (tests) where

import Control.Lens ((&), (.~), (?~))
import Data.Generics.Labels ()
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Okf.Bundle qualified as Okf
import Okf.Index qualified as Okf
import Okf.Validation qualified as Okf
import Seihou.OKF.Docs.Render (builtinProfileDescriptor)
import Seihou.OKF.Extension.Docs
import System.Directory (createDirectoryIfMissing, doesFileExist, doesPathExist)
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.Extension.Docs" spec

spec :: Spec
spec = do
  describe "runDocs" $ do
    it "writes and validates an OKF bundle for a registry" $ do
      withSystemTempDirectory "seihou-okf-docs" $ \tmpDir -> do
        let registryDir = tmpDir </> "registry"
            outDir = tmpDir </> "out"
        writeFixtureRegistry registryDir
        result <- runDocs (docsOpts registryDir outDir False)
        result `shouldBe` Right ("Wrote 3 concepts to " <> T.pack outDir)
        doesFileExist (outDir </> "modules" </> "base.md") `shouldReturn` True
        doesFileExist (outDir </> "recipes" </> "base-recipe.md") `shouldReturn` True
        doesFileExist (outDir </> "index.md") `shouldReturn` True
        rootIndex <- TIO.readFile (outDir </> "index.md")
        rootIndex `shouldSatisfy` T.isInfixOf "okf_version: \"0.2\""
        doesFileExist (outDir </> "modules" </> "index.md") `shouldReturn` True
        doesFileExist (outDir </> "recipes" </> "index.md") `shouldReturn` True
        doesFileExist (outDir </> "registry" </> "fixture-registry.md") `shouldReturn` True
        doesFileExist (outDir </> "profile.dhall") `shouldReturn` True
        writtenProfile <- TIO.readFile (outDir </> "profile.dhall")
        writtenProfile `shouldBe` builtinProfileDescriptor
        walked <- Okf.walkBundle outDir
        case walked of
          Left err -> expectationFailure ("Expected walkBundle success, got " <> show err)
          Right concepts ->
            Okf.validateBundle
              Okf.PermissiveConformance
              Okf.VersionUndeclared
              (Okf.bundleInventoryOfConcepts concepts)
              concepts
              `shouldBe` []

    it "refuses to overwrite a non-empty output directory without force" $ do
      withSystemTempDirectory "seihou-okf-docs-force" $ \tmpDir -> do
        let registryDir = tmpDir </> "registry"
            outDir = tmpDir </> "out"
        writeFixtureRegistry registryDir
        first <- runDocs (docsOpts registryDir outDir False)
        first `shouldBe` Right ("Wrote 3 concepts to " <> T.pack outDir)
        second <- runDocs (docsOpts registryDir outDir False)
        second `shouldBe` Left ("output directory is not empty: " <> T.pack outDir <> "; pass --force to overwrite")
        forced <- runDocs (docsOpts registryDir outDir True)
        forced `shouldBe` Right ("Wrote 3 concepts to " <> T.pack outDir)

    it "refuses to write a bundle that violates the house profile" $ do
      withSystemTempDirectory "seihou-okf-docs-profile" $ \tmpDir -> do
        let registryDir = tmpDir </> "registry"
            outDir = tmpDir </> "out"
            profilePath = tmpDir </> "demanding.dhall"
        writeFixtureRegistry registryDir
        TIO.writeFile profilePath demandingProfile
        result <-
          runDocs (docsOpts registryDir outDir False & #profile ?~ profilePath)
        case result of
          Right summary -> expectationFailure ("Expected a profile violation, got " <> show summary)
          -- Specifically a violation, not an unreadable or uncompilable
          -- descriptor, which would also mention the house profile.
          Left err -> err `shouldSatisfy` T.isInfixOf "house profile: modules/base: missing required field stale_after"
        -- Nothing at all reached disk: the check runs before the output
        -- directory is even prepared.
        doesPathExist outDir `shouldReturn` False

    it "skips profile enforcement with --no-profile" $ do
      withSystemTempDirectory "seihou-okf-docs-no-profile" $ \tmpDir -> do
        let registryDir = tmpDir </> "registry"
            outDir = tmpDir </> "out"
            profilePath = tmpDir </> "demanding.dhall"
        writeFixtureRegistry registryDir
        TIO.writeFile profilePath demandingProfile
        result <-
          runDocs
            ( docsOpts registryDir outDir False
                & #profile ?~ profilePath
                & #noProfile .~ True
            )
        result `shouldBe` Right ("Wrote 3 concepts to " <> T.pack outDir)

    it "derives its demanding fixture profile from the real descriptor" $ do
      demandingProfile `shouldNotBe` builtinProfileDescriptor

    it "reports a missing registry file" $ do
      withSystemTempDirectory "seihou-okf-docs-missing" $ \tmpDir -> do
        let registryDir = tmpDir </> "missing"
        result <- runDocs (docsOpts registryDir (tmpDir </> "out") False)
        result `shouldBe` Left ("registry file not found: " <> T.pack (registryDir </> "seihou-registry.dhall"))

-- | The default option set for a fixture run: strict validation, no generation
-- date, so the written bundle is byte-stable across runs.
docsOpts :: FilePath -> FilePath -> Bool -> DocsOpts
docsOpts registryDir outDir force =
  DocsOpts
    { dir = registryDir,
      out = outDir,
      force = force,
      generatedAt = Nothing,
      permissive = False,
      profile = Nothing,
      noProfile = False
    }

-- | The house profile, plus one required frontmatter key the generator never
-- emits, so that enforcement has something real to reject. Derived from the
-- real descriptor rather than hand-written, so it stays a valid profile.
--
-- 'demandingProfileIsDifferent' guards the substitution: if the descriptor is
-- reworded so the anchor no longer matches, that test fails loudly rather than
-- these two silently checking nothing.
demandingProfile :: T.Text
demandingProfile =
  T.replace
    demandingProfileAnchor
    ("[ scalar \"stale_after\" \"A key this generator never emits.\"\n              , scalar \"type\"")
    builtinProfileDescriptor

demandingProfileAnchor :: T.Text
demandingProfileAnchor = "[ scalar \"type\""

writeFixtureRegistry :: FilePath -> IO ()
writeFixtureRegistry registryDir = do
  createDirectoryIfMissing True (registryDir </> "modules" </> "base")
  createDirectoryIfMissing True (registryDir </> "recipes" </> "base-recipe")
  writeFile (registryDir </> "seihou-registry.dhall") registryDhall
  writeFile (registryDir </> "modules" </> "base" </> "module.dhall") moduleDhall
  writeFile (registryDir </> "recipes" </> "base-recipe" </> "recipe.dhall") recipeDhall

registryDhall :: String
registryDhall =
  "{ repoName = \"fixture-registry\"\n\
  \, repoDescription = Some \"Fixture registry\"\n\
  \, modules = [ { name = \"base\", version = Some \"1.0.0\", path = \"modules/base\", description = Some \"Base module\", tags = [ \"haskell\" ] } ]\n\
  \, recipes = [ { name = \"base-recipe\", version = Some \"0.1.0\", path = \"recipes/base-recipe\", description = Some \"Recipe\", tags = [ \"recipe\" ] } ]\n\
  \, blueprints = [] : List { name : Text, version : Optional Text, path : Text, description : Optional Text, tags : List Text }\n\
  \, prompts = [] : List { name : Text, version : Optional Text, path : Text, description : Optional Text, tags : List Text }\n\
  \}"

moduleDhall :: String
moduleDhall =
  "{ name = \"base\"\n\
  \, version = Some \"1.0.0\"\n\
  \, description = Some \"Base module\"\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 = [] : List Text\n\
  \}"

recipeDhall :: String
recipeDhall =
  "{ name = \"base-recipe\"\n\
  \, version = Some \"0.1.0\"\n\
  \, description = Some \"Recipe\"\n\
  \, modules = [ \"base\" ]\n\
  \, vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }\n\
  \, prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }\n\
  \}"