packages feed

seihou-cli-0.16.0.0: test/Seihou/CLI/InstalledRunnablesSpec.hs

module Seihou.CLI.InstalledRunnablesSpec (tests) where

import Control.Lens ((^.))
import Control.Monad (forM)
import Data.ByteString qualified as BS
import Data.Generics.Labels ()
import Data.List (sort, sortOn)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Seihou.CLI.InstallClosure (installClosureInto)
import Seihou.CLI.InstallShared (OriginInfo (..), readOriginInfo)
import Seihou.CLI.InstalledRunnables (RefreshOptions (..), refreshInstalledRunnables)
import Seihou.CLI.UpgradeReport (UpgradeEntry (..), UpgradeStatus (..))
import Seihou.Core.Module (DiscoveredRunnable (..), ModuleSource (..), RunnableKind (..))
import System.Directory
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import System.Process (callProcess)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "Seihou.CLI.InstalledRunnables" $ do
  it "refreshes a single-blueprint repository, then reports it up to date" $
    withSystemTempDirectory "runnables-single" $ \tmp -> do
      let producer = tmp </> "producer"
          cache = tmp </> "cache"
      createDirectoryIfMissing True producer
      writeBlueprint (producer </> "blueprint.dhall") "bp" "0.1.0" noBaseModules
      initProducer producer
      installClosureInto cache False producer (fileUrl producer) [("bp", producer)] `shouldReturn` Right ()
      writeBlueprint (producer </> "blueprint.dhall") "bp" "0.1.1" noBaseModules
      commitProducer producer
      (rows, names) <- refresh (RefreshOptions False False) cache "bp" KindBlueprint
      rows `shouldBe` [UpgradeEntry "bp" (Just "0.1.0") (Just "0.1.1") Upgraded]
      names `shouldBe` ["bp"]
      TIO.readFile (cache </> "bp" </> "blueprint.dhall") >>= (`shouldSatisfy` T.isInfixOf "0.1.1")
      fmap (^. #version) <$> readOriginInfo (cache </> "bp") `shouldReturn` Just (Just "0.1.1")
      (again, _) <- refresh (RefreshOptions False False) cache "bp" KindBlueprint
      again `shouldBe` [UpgradeEntry "bp" (Just "0.1.1") (Just "0.1.1") AlreadyUpToDate]

  it "previews a refresh under dry run without changing the cache" $
    withSystemTempDirectory "runnables-dry-run" $ \tmp -> do
      let producer = tmp </> "producer"
          cache = tmp </> "cache"
      createDirectoryIfMissing True producer
      writeBlueprint (producer </> "blueprint.dhall") "bp" "0.1.0" noBaseModules
      initProducer producer
      installClosureInto cache False producer (fileUrl producer) [("bp", producer)] `shouldReturn` Right ()
      before <- snapshot cache
      writeBlueprint (producer </> "blueprint.dhall") "bp" "0.1.1" noBaseModules
      commitProducer producer
      (rows, _) <- refresh (RefreshOptions True False) cache "bp" KindBlueprint
      rows `shouldBe` [UpgradeEntry "bp" (Just "0.1.0") (Just "0.1.1") Upgraded]
      snapshot cache `shouldReturn` before

  it "refreshes a baseline module through its blueprint's closure" $
    withSystemTempDirectory "runnables-closure" $ \tmp -> do
      let producer = tmp </> "producer"
          cache = tmp </> "cache"
      registryProducer producer "1.0.0"
      installClosureInto cache False producer (fileUrl producer) [("bp", producer </> "bp")] `shouldReturn` Right ()
      doesFileExist (cache </> "demo" </> "module.dhall") `shouldReturn` True
      writeModule producer "demo" "1.1.0"
      commitProducer producer
      (rows, names) <- refresh (RefreshOptions False False) cache "bp" KindBlueprint
      sortOn (^. #moduleName) rows
        `shouldBe` sortOn
          (^. #moduleName)
          [ UpgradeEntry "demo" (Just "1.0.0") (Just "1.1.0") Upgraded,
            UpgradeEntry "bp" (Just "0.1.0") (Just "0.1.0") AlreadyUpToDate
          ]
      sort names `shouldBe` ["bp", "demo"]
      TIO.readFile (cache </> "demo" </> "module.dhall") >>= (`shouldSatisfy` T.isInfixOf "1.1.0")

  it "refuses a closure member whose provenance names another source and leaves it byte-identical" $
    withSystemTempDirectory "runnables-refusal" $ \tmp -> do
      let producer = tmp </> "producer"
          cache = tmp </> "cache"
          elsewhere = "file:///elsewhere/other.git"
      registryProducer producer "1.0.0"
      installClosureInto cache False producer (fileUrl producer) [("bp", producer </> "bp")] `shouldReturn` Right ()
      TIO.writeFile
        (cache </> "demo" </> ".seihou-origin.json")
        ("{\"sourceUrl\":\"" <> elsewhere <> "\",\"version\":\"1.0.0\",\"tags\":[],\"installedAt\":\"2026-11-11T00:00:00Z\"}")
      writeFile (cache </> "demo" </> "marker") "original"
      before <- snapshot (cache </> "demo")
      writeModule producer "demo" "1.1.0"
      commitProducer producer
      (rows, _) <- refresh (RefreshOptions False False) cache "bp" KindBlueprint
      case [entry | entry <- rows, entry ^. #moduleName == "demo"] of
        [entry] -> case entry ^. #upgradeStatus of
          UpgradeFailed reason -> do
            reason `shouldSatisfy` T.isInfixOf elsewhere
            reason `shouldSatisfy` T.isInfixOf (fileUrl producer)
          other -> expectationFailure ("expected a refusal, got " <> show other)
        other -> expectationFailure ("expected one demo row, got " <> show other)
      snapshot (cache </> "demo") `shouldReturn` before

  it "refreshes a single-prompt repository" $
    withSystemTempDirectory "runnables-prompt" $ \tmp -> do
      let producer = tmp </> "producer"
          cache = tmp </> "cache"
      createDirectoryIfMissing True producer
      writePrompt (producer </> "prompt.dhall") "review" "0.2.0"
      initProducer producer
      installClosureInto cache False producer (fileUrl producer) [("review", producer)] `shouldReturn` Right ()
      writePrompt (producer </> "prompt.dhall") "review" "0.3.0"
      commitProducer producer
      (rows, _) <- refresh (RefreshOptions False False) cache "review" KindPrompt
      rows `shouldBe` [UpgradeEntry "review" (Just "0.2.0") (Just "0.3.0") Upgraded]
      TIO.readFile (cache </> "review" </> "prompt.dhall") >>= (`shouldSatisfy` T.isInfixOf "0.3.0")

-- | Refresh one installed entry, read from the cache as discovery would.
refresh :: RefreshOptions -> FilePath -> Text -> RunnableKind -> IO ([UpgradeEntry], [Text])
refresh opts cache key kind = do
  let dir = cache </> T.unpack key
  origin <- readOriginInfo dir
  case origin of
    Nothing -> fail ("no origin metadata in " <> dir)
    Just info ->
      refreshInstalledRunnables opts cache [(DiscoveredRunnable key Nothing kind SourceInstalled dir False Nothing, info)]

fileUrl :: FilePath -> Text
fileUrl path = "file://" <> T.pack path

initProducer :: FilePath -> IO ()
initProducer repo = do
  callProcess "git" ["-C", repo, "init", "-q"]
  commitProducer repo

commitProducer :: FilePath -> IO ()
commitProducer repo = do
  callProcess "git" ["-C", repo, "add", "-A"]
  callProcess "git" ["-C", repo, "-c", "user.name=Seihou Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-qm", "test: producer"]

-- | A registry with module @demo@ and blueprint @bp@, which names @demo@ as
-- its baseline.
registryProducer :: FilePath -> String -> IO ()
registryProducer producer moduleVersion = do
  writeModule producer "demo" moduleVersion
  createDirectoryIfMissing True (producer </> "bp")
  writeBlueprint (producer </> "bp" </> "blueprint.dhall") "bp" "0.1.0" demoBaseModule
  TIO.writeFile (producer </> "seihou-registry.dhall") $
    T.unlines
      [ "{ repoName = \"fixture\"",
        ", repoDescription = None Text",
        ", modules = [ { name = \"demo\", version = None Text, path = \"demo\", description = None Text, tags = [] : List Text } ]",
        ", recipes = [] : List { name : Text, version : Optional Text, path : Text, description : Optional Text, tags : List Text }",
        ", blueprints = [ { name = \"bp\", version = None Text, path = \"bp\", description = None Text, tags = [] : List Text } ]",
        ", prompts = [] : List { name : Text, version : Optional Text, path : Text, description : Optional Text, tags : List Text }",
        "}"
      ]
  initProducer producer

writeModule :: FilePath -> String -> String -> IO ()
writeModule root name version = do
  schema <- canonicalizePath "../schema/Module.dhall"
  let dir = root </> name
  createDirectoryIfMissing True dir
  writeFile (dir </> "module.dhall") ("let Module = " <> schema <> " in Module::{ name = \"" <> name <> "\", version = Some \"" <> version <> "\" }")

writeBlueprint :: FilePath -> Text -> Text -> Text -> IO ()
writeBlueprint path name version baseModules =
  TIO.writeFile path $
    T.unlines
      [ "{ name = \"" <> name <> "\"",
        ", version = Some \"" <> version <> "\"",
        ", description = Some \"Refresh fixture\"",
        ", prompt = \"Do the work.\"",
        ", vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }",
        ", prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }",
        ", baseModules = " <> baseModules,
        ", files = [] : List { src : Text, description : Optional Text }",
        ", allowedTools = None (List Text)",
        ", tags = [] : List Text",
        "}"
      ]

writePrompt :: FilePath -> Text -> Text -> IO ()
writePrompt path name version =
  TIO.writeFile path $
    T.unlines
      [ "{ name = \"" <> name <> "\"",
        ", version = Some \"" <> version <> "\"",
        ", description = None Text",
        ", prompt = \"Review the current changes.\"",
        ", vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }",
        ", prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }",
        ", commandVars = [] : List { name : Text, run : Text, workDir : Optional Text, when : Optional Text, trim : Bool, maxBytes : Optional Natural }",
        ", files = [] : List { src : Text, description : Optional Text }",
        ", allowedTools = None (List Text)",
        ", tags = [] : List Text",
        ", launch = None { provider : Optional Text, mode : Optional Text, model : Optional Text }",
        "}"
      ]

noBaseModules :: Text
noBaseModules = "[] : List { module : Text, vars : List { name : Text, value : Text } }"

demoBaseModule :: Text
demoBaseModule = "[ { module = \"demo\", vars = [] : List { name : Text, value : Text } } ]"

-- | Every file under a directory with its bytes.
snapshot :: FilePath -> IO (Map.Map FilePath BS.ByteString)
snapshot root = Map.fromList <$> walk ""
  where
    walk relative = do
      let dir = if null relative then root else root </> relative
      names <- sort <$> listDirectory dir
      fmap concat $ forM names $ \name -> do
        let child = if null relative then name else relative </> name
        isDir <- doesDirectoryExist (root </> child)
        if isDir
          then walk child
          else do
            bytes <- BS.readFile (root </> child)
            pure [(child, bytes)]