packages feed

seihou-cli-0.14.0.0: test/Seihou/CLI/RecipeExtensionE2ESpec.hs

module Seihou.CLI.RecipeExtensionE2ESpec (tests) where

import Control.Exception (bracket)
import Control.Lens ((^.))
import Control.Monad (forM_)
import Data.Aeson qualified as Aeson
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Data.Generics.Labels ()
import Data.List (find)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Text.IO qualified as TIO
import Seihou.CLI.CommandExecution (CommandPolicy (..))
import Seihou.CLI.SeedRunE2ESpec (prepare)
import Seihou.CLI.Update qualified as U
import Seihou.CLI.Update.Types (UpdateError (..))
import System.Directory qualified as D
import System.Environment (getEnvironment, lookupEnv, setEnv, unsetEnv)
import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import System.Process (CreateProcess (..), callProcess, proc, readCreateProcessWithExitCode)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

invoke binary root flags = invokeCommand binary root ("run" : "child" : flags)

invokeCommand binary root args = do
  inherited <- getEnvironment
  configured <- D.doesFileExist (root </> "fixture-gitconfig")
  let overrides = zip ["HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"] [root </> "home", root </> "config", root </> "cache"] <> [("GIT_CONFIG_GLOBAL", root </> "fixture-gitconfig") | configured]
      keys = map fst overrides
      environment = overrides <> filter (\(key, _) -> key `notElem` keys && not ("SEIHOU_VAR_" `T.isPrefixOf` T.pack key)) inherited
  (code, out, err) <- readCreateProcessWithExitCode (proc binary args) {cwd = Just (if configured then root </> "project" else root), env = Just environment} ""
  pure (code, T.pack (out <> err))

fixture root = do
  (binary, member) <- prepare root
  TIO.writeFile (member </> "module.dhall") "let S = ./schema/package.dhall in S.Module::{name=\"demo\",version=Some \"1\",steps=[S.Step::{strategy=\"copy\",src=\"starter\",dest=\"value.txt\"}],commands=[S.Command::{action=S.Command.Action.Apply \"printf once >> command-marker\"}]}"
  writeRecipe root "base" "1" "[\"demo\"]" ""
  writeRecipe root "child" "1" "[] : List Text" ", extends = Some {recipe = \"base\"}"
  pure binary

writeRecipe root name version members extra = do
  let dir = root </> ".seihou/modules" </> name
  D.createDirectoryIfMissing True dir
  writeFile (dir </> "recipe.dhall") $ "{name=" <> show name <> ",version=Some " <> show version <> ",description=None Text,modules=" <> members <> ",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)}" <> extra <> "}"

readManifest root = LBS.fromStrict <$> BS.readFile (root </> ".seihou/manifest.json")

tests :: IO TestTree
tests = testSpec "RecipeExtension" $ do
  it "inspects inherited declarations and literal sources through vars without generation" $
    withSystemTempDirectory "recipe-chain-vars" $ \root -> do
      binary <- fixture root
      let base = root </> ".seihou/modules/base/recipe.dhall"
      body <- TIO.readFile base
      TIO.writeFile base (T.replace "vars=[] : List {name : Text,type : Text,default : Optional Text,description : Optional Text,required : Bool,validation : Optional Text}" "vars=[{name=\"project.owner\",type=\"text\",default=Some \"base\",description=None Text,required=False,validation=None Text}]" body)
      writeRecipe root "child" "1" "[] : List Text" ", extends=Some {recipe=\"base\"},moduleOverrides=[{module=\"demo\",vars=[{name=\"project.owner\",value=\"child\"}]}]"
      declarations <- successful binary root ["vars", "child"]
      declarations `shouldSatisfy` T.isInfixOf "project.owner"
      explained <- successful binary root ["vars", "child", "--explain"]
      explained `shouldSatisfy` T.isInfixOf "literal recipe binding from 'child'"
      explained `shouldSatisfy` T.isInfixOf "project.owner"
      explained `shouldNotSatisfy` T.isInfixOf "Unresolved optional variables:"
      D.doesFileExist (root </> ".seihou/manifest.json") `shouldReturn` False
      D.doesFileExist (root </> "command-marker") `shouldReturn` False

  it "records the chain and refuses base-only downgrades before files, commands or manifest writes" $
    withSystemTempDirectory "recipe-chain-guard" $ \root -> do
      binary <- fixture root
      (code, output) <- invoke binary root []
      if code == ExitSuccess then pure () else expectationFailure (T.unpack output)
      before <- readManifest root
      case Aeson.eitherDecode before :: Either String Aeson.Value of
        Right (Aeson.Object fields) -> do
          KM.lookup "version" fields `shouldBe` Just (Aeson.toJSON (13 :: Int))
          T.pack (show fields) `shouldSatisfy` T.isInfixOf "recipeChain"
          T.pack (show fields) `shouldSatisfy` T.isInfixOf "descriptorHash"
        other -> expectationFailure (show other)
      writeRecipe root "base" "0.9" "[\"demo\"]" ""
      (refused, diagnostic) <- invoke binary root []
      refused `shouldBe` ExitFailure 1
      diagnostic `shouldSatisfy` T.isInfixOf "base"
      readManifest root `shouldReturn` before
      readFile (root </> "command-marker") `shouldReturn` "once"
      (allowed, visible) <- invoke binary root ["--allow-downgrade"]
      allowed `shouldBe` ExitSuccess
      visible `shouldSatisfy` T.isInfixOf "allow-downgrade"
      readFile (root </> "command-marker") `shouldReturn` "onceonce"

  it "previews same-version descriptor changes without writing provenance" $
    withSystemTempDirectory "recipe-chain-digest" $ \root -> do
      binary <- fixture root
      (code, _) <- invoke binary root []
      code `shouldBe` ExitSuccess
      before <- readManifest root
      let descriptor = root </> ".seihou/modules/base/recipe.dhall"
      original <- TIO.readFile descriptor
      TIO.writeFile descriptor (T.replace "description=None Text" "description=Some \"New base description\"" original)
      (previewed, output) <- invoke binary root ["--dry-run"]
      previewed `shouldBe` ExitSuccess
      output `shouldSatisfy` T.isInfixOf "Recipe descriptor changed: base"
      readManifest root `shouldReturn` before
      readFile (root </> "command-marker") `shouldReturn` "once"

  it "prepares old unknown history in memory and adopts it only on an accepted run" $
    withSystemTempDirectory "recipe-chain-adoption" $ \root -> do
      binary <- fixture root
      (code, _) <- invoke binary root []
      code `shouldBe` ExitSuccess
      bytes <- readManifest root
      let Right (Aeson.Object fields) = Aeson.eitherDecode bytes
          withoutChain (Aeson.Object app) = Aeson.Object (KM.delete "recipeChain" app)
          withoutChain other = other
          Just (Aeson.Array apps) = KM.lookup "applications" fields
          legacy = Aeson.Object (KM.insert "version" (Aeson.toJSON (12 :: Int)) (KM.insert "applications" (Aeson.Array (fmap withoutChain apps)) fields))
      LBS.writeFile (root </> ".seihou/manifest.json") (Aeson.encode legacy)
      old <- readManifest root
      (previewed, output) <- invoke binary root ["--dry-run"]
      previewed `shouldBe` ExitSuccess
      output `shouldSatisfy` T.isInfixOf "prior chain is unknown"
      readManifest root `shouldReturn` old
      (accepted, _) <- invoke binary root []
      accepted `shouldBe` ExitSuccess
      adopted <- readManifest root
      adopted `shouldSatisfy` (/= old)

  it "retains generated provenance but no command receipt when a run command fails" $
    withSystemTempDirectory "recipe-chain-command-failure" $ \root -> do
      binary <- fixture root
      let descriptor = root </> ".seihou/modules/demo/module.dhall"
      source <- TIO.readFile descriptor
      TIO.writeFile descriptor (T.replace "printf once >> command-marker" "printf once >> command-marker; exit 1" source)
      (code, _) <- invoke binary root []
      code `shouldBe` ExitFailure 1
      D.doesFileExist (root </> "value.txt") `shouldReturn` True
      readFile (root </> "command-marker") `shouldReturn` "once"
      bytes <- readManifest root
      case Aeson.eitherDecode bytes :: Either String Aeson.Value of
        Right (Aeson.Object fields) -> case KM.lookup "applications" fields of
          Just (Aeson.Array apps) -> forM_ apps $ \case
            Aeson.Object app -> do
              KM.lookup "recipeChain" app `shouldSatisfy` (\value -> value /= Nothing && value /= Just Aeson.Null)
              KM.lookup "commandReceipts" app `shouldSatisfy` (\value -> value == Nothing || value == Just (Aeson.object []))
            other -> expectationFailure (show other)
          other -> expectationFailure (show other)
        other -> expectationFailure (show other)

  it "does not create a manifest directory during preview of a cache-only recipe" $
    withSystemTempDirectory "recipe-chain-no-write" $ \root -> do
      binary <- fixture root
      let cache = root </> "config/seihou/installed"
      D.createDirectoryIfMissing True cache
      forM_ ["base", "child", "demo"] $ \name -> D.renameDirectory (root </> ".seihou/modules" </> name) (cache </> name)
      D.removeDirectory (root </> ".seihou/modules")
      D.removeDirectory (root </> ".seihou")
      (code, output) <- invoke binary root ["--dry-run"]
      if code == ExitSuccess then pure () else expectationFailure (T.unpack output)
      D.doesDirectoryExist (root </> ".seihou") `shouldReturn` False

  it "accepts base-only metadata through ordinary update without rerunning commands, then becomes a no-op" $
    withSystemTempDirectory "recipe-chain-update" $ \root -> do
      binary <- fixture root
      (ran, _) <- invoke binary root []
      ran `shouldBe` ExitSuccess
      before <- readManifest root
      writeRecipe root "base" "1.1" "[\"demo\"]" ""
      (previewed, preview) <- invokeCommand binary root ["update", "child", "--dry-run", "--json"]
      previewed `shouldBe` ExitSuccess
      case Aeson.eitherDecode (LBS.fromStrict (TE.encodeUtf8 preview)) :: Either String Aeson.Value of
        Right (Aeson.Object fields) -> do
          KM.lookup "schemaVersion" fields `shouldBe` Just (Aeson.toJSON (1 :: Int))
          KM.lookup "alreadyUpToDate" fields `shouldBe` Just (Aeson.Bool False)
          KM.lookup "recipeChains" fields `shouldSatisfy` (\case Just (Aeson.Array xs) -> length xs == 1; _ -> False)
          KM.lookup "versions" fields `shouldBe` Just (Aeson.toJSON ([] :: [Aeson.Value]))
        other -> expectationFailure (show other)
      readManifest root `shouldReturn` before
      readFile (root </> "command-marker") `shouldReturn` "once"
      (eof, _) <- invokeCommand binary root ["update", "child"]
      eof `shouldBe` ExitSuccess
      readManifest root `shouldReturn` before
      (accepted, output) <- invokeCommand binary root ["update", "child", "--json"]
      if accepted == ExitSuccess then pure () else expectationFailure (T.unpack output)
      readManifest root >>= (`shouldSatisfy` (/= before))
      readFile (root </> "command-marker") `shouldReturn` "once"
      (repeated, unchanged) <- invokeCommand binary root ["update", "child", "--dry-run"]
      repeated `shouldBe` ExitSuccess
      unchanged `shouldSatisfy` T.isInfixOf "Already up to date"

  it "updates a local recipe with read-only definitions and schema payloads without changing the originals" $
    withSystemTempDirectory "recipe-chain-read-only" $ \root -> do
      binary <- fixture root
      let files = [root </> ".seihou/modules/demo/module.dhall", root </> ".seihou/modules/demo/schema/Migration.dhall"]
      forM_ files $ \file -> D.getPermissions file >>= D.setPermissions file . D.setOwnerWritable False
      permissions <- mapM D.getPermissions files
      before <- mapM BS.readFile files
      _ <- successful binary root ["run", "child"]
      writeRecipe root "base" "1.1" "[\"demo\"]" ""
      _ <- successful binary root ["update", "child", "--json"]
      mapM D.getPermissions files `shouldReturn` permissions
      mapM BS.readFile files `shouldReturn` before
      readFile (root </> "command-marker") `shouldReturn` "once"

  it "adopts unknown recipe history through update only on acceptance" $
    withSystemTempDirectory "recipe-chain-update-adopt" $ \root -> do
      binary <- fixture root
      (ran, _) <- invoke binary root []
      ran `shouldBe` ExitSuccess
      bytes <- readManifest root
      let Right (Aeson.Object fields) = Aeson.eitherDecode bytes
          withoutChain (Aeson.Object app) = Aeson.Object (KM.delete "recipeChain" app)
          withoutChain other = other
          Just (Aeson.Array apps) = KM.lookup "applications" fields
          legacy = Aeson.Object (KM.insert "version" (Aeson.toJSON (12 :: Int)) (KM.insert "applications" (Aeson.Array (fmap withoutChain apps)) fields))
      LBS.writeFile (root </> ".seihou/manifest.json") (Aeson.encode legacy)
      old <- readManifest root
      (previewed, output) <- invokeCommand binary root ["update", "child", "--dry-run"]
      previewed `shouldBe` ExitSuccess
      output `shouldSatisfy` T.isInfixOf "prior chain is unknown"
      readManifest root `shouldReturn` old
      (accepted, applied) <- invokeCommand binary root ["update", "child", "--json"]
      if accepted == ExitSuccess then pure () else expectationFailure (T.unpack applied)
      readManifest root >>= (`shouldSatisfy` (/= old))
      readFile (root </> "command-marker") `shouldReturn` "once"

  it "guards a downgraded base during update before commands or publication" $
    withSystemTempDirectory "recipe-chain-update-guard" $ \root -> do
      binary <- fixture root
      (ran, _) <- invoke binary root []
      ran `shouldBe` ExitSuccess
      before <- readManifest root
      writeRecipe root "base" "0.9" "[\"demo\"]" ""
      (refused, output) <- invokeCommand binary root ["update", "child", "--json"]
      refused `shouldBe` ExitFailure 1
      output `shouldSatisfy` T.isInfixOf "base"
      readManifest root `shouldReturn` before
      readFile (root </> "command-marker") `shouldReturn` "once"
      (allowed, preview) <- invokeCommand binary root ["update", "child", "--allow-downgrade", "--dry-run"]
      allowed `shouldBe` ExitSuccess
      preview `shouldSatisfy` T.isInfixOf "allow-downgrade"

  it "rolls recipe-chain evidence and generated files back after a changed command fails" $
    withSystemTempDirectory "recipe-chain-update-failure" $ \root -> do
      binary <- fixture root
      (ran, _) <- invoke binary root []
      ran `shouldBe` ExitSuccess
      before <- readManifest root
      fileBefore <- readFile (root </> "value.txt")
      writeRecipe root "base" "1.1" "[\"demo\"]" ""
      let path = root </> ".seihou/modules/demo/module.dhall"
      body <- TIO.readFile path
      TIO.writeFile path (T.replace "printf once >> command-marker" "exit 7" body)
      (failed, output) <- invokeCommand binary root ["update", "child", "--json"]
      failed `shouldBe` ExitFailure 1
      output `shouldSatisfy` T.isInfixOf "command_failed"
      readManifest root `shouldReturn` before
      readFile (root </> "value.txt") `shouldReturn` fileBefore
      readFile (root </> "command-marker") `shouldReturn` "once"

  it "refreshes an unchanged recipe's base closure once, preserves previews, and replays from an empty cache" $
    withSystemTempDirectory "recipe-chain-remote" $ \root -> do
      binary <- remoteFixture root
      _ <- successful binary root ["install", remoteUrl "child", "--module", "child"]
      _ <- successful binary root ["run", "child"]
      before <- readManifest (root </> "project")
      let installed = root </> "config/seihou/installed"
      oldBase <- BS.readFile (installed </> "base/recipe.dhall")
      writeRemoteRecipe root "base" "1.1" ("[ {module=\"demo\",source=Some \"" <> remoteUrl "demo" <> "\",vars=[] : List {name : Text,value : Text}} ]") ""
      commitRemote root "base"
      preview <- successful binary root ["upgrade", "child", "demo", "--dry-run", "--json"]
      case Aeson.eitherDecode (LBS.fromStrict (TE.encodeUtf8 preview)) :: Either String Aeson.Value of
        Right (Aeson.Array entries) -> length entries `shouldBe` 3
        other -> expectationFailure (show other)
      readManifest (root </> "project") `shouldReturn` before
      BS.readFile (installed </> "base/recipe.dhall") `shouldReturn` oldBase
      allRoots <- successful binary root ["upgrade", "--dry-run", "--json"]
      case Aeson.eitherDecode (LBS.fromStrict (TE.encodeUtf8 allRoots)) :: Either String Aeson.Value of
        Right (Aeson.Array entries) -> length entries `shouldBe` 3
        other -> expectationFailure (show other)
      _ <- successful binary root ["upgrade", "child", "--json"]
      readManifest (root </> "project") `shouldReturn` before
      BS.readFile (installed </> "base/recipe.dhall") >>= (`shouldSatisfy` (/= oldBase))
      updated <- successful binary root ["update", "child", "--json"]
      updated `shouldSatisfy` T.isInfixOf "skippedUnchanged"
      readFile (root </> "project/command-marker") `shouldReturn` "once"
      unchanged <- successful binary root ["update", "child", "--dry-run"]
      unchanged `shouldSatisfy` T.isInfixOf "Already up to date"
      D.removePathForcibly installed
      _ <- successful binary root ["update", "child", "--json"]
      -- Cache reconstruction is published as part of the accepted update.
      D.doesFileExist (installed </> "base/recipe.dhall") `shouldReturn` True
      readFile (root </> "project/command-marker") `shouldReturn` "once"

  it "acquires new external bases and members without fetching a removed base" $
    withSystemTempDirectory "recipe-chain-reparent" $ \root -> do
      binary <- remoteFixture root
      _ <- successful binary root ["install", remoteUrl "child", "--module", "child"]
      _ <- successful binary root ["run", "child"]
      let producers = root </> "producers"
      D.renameDirectory (producers </> "base") (producers </> "unavailable-base")
      D.renameDirectory (producers </> "demo") (producers </> "fresh")
      let path = producers </> "fresh/module.dhall"
      body <- TIO.readFile path
      TIO.writeFile path (T.replace "name=\"demo\"" "name=\"fresh\"" (T.replace "dest=\"value.txt\"" "dest=\"fresh.txt\"" body))
      commitRemote root "fresh"
      writeRemoteRecipe root "next-base" "1" ("[ {module=\"fresh\",source=Some \"" <> remoteUrl "fresh" <> "\",vars=[] : List {name : Text,value : Text}} ]") ""
      initRemote root "next-base"
      writeRemoteRecipe root "child" "1" "[] : List Text" (", extends=Some {recipe=\"next-base\",source=Some \"" <> remoteUrl "next-base" <> "\"}")
      commitRemote root "child"
      preview <- successful binary root ["update", "child", "--dry-run"]
      preview `shouldSatisfy` T.isInfixOf "next-base"
      _ <- successful binary root ["update", "child", "--json"]
      D.doesFileExist (root </> "project/fresh.txt") `shouldReturn` True
      D.doesFileExist (root </> "project/value.txt") `shouldReturn` False
      unchanged <- successful binary root ["update", "child", "--dry-run"]
      unchanged `shouldSatisfy` T.isInfixOf "Already up to date"

  it "refuses an out-of-range base during update and upgrade even with the application downgrade override" $
    withSystemTempDirectory "recipe-chain-range-update" $ \root -> do
      binary <- remoteFixture root
      _ <- successful binary root ["install", remoteUrl "child", "--module", "child"]
      _ <- successful binary root ["run", "child"]
      before <- readManifest (root </> "project")
      let installed = root </> "config/seihou/installed/base/recipe.dhall"
      cached <- BS.readFile installed
      writeRemoteRecipe root "base" "2" ("[ {module=\"demo\",source=Some \"" <> remoteUrl "demo" <> "\",vars=[] : List {name : Text,value : Text}} ]") ""
      commitRemote root "base"
      forM_ [["update", "child", "--allow-downgrade", "--json"], ["upgrade", "child", "--json"]] $ \args -> do
        (code, output) <- invokeCommand binary root args
        code `shouldBe` ExitFailure 1
        output `shouldSatisfy` T.isInfixOf "base"
        readManifest (root </> "project") `shouldReturn` before
        BS.readFile installed `shouldReturn` cached
        readFile (root </> "project/command-marker") `shouldReturn` "once"

  it "keeps a recipe refresh together when skip-unversioned excludes a changed recipe" $
    withSystemTempDirectory "recipe-chain-upgrade-skip" $ \root -> do
      binary <- remoteFixture root
      _ <- successful binary root ["install", remoteUrl "child", "--module", "child"]
      let cached = root </> "config/seihou/installed/child/recipe.dhall"
      before <- BS.readFile cached
      let descriptor = root </> "producers/child/recipe.dhall"
      body <- TIO.readFile descriptor
      TIO.writeFile descriptor (T.replace "version=Some \"1\"" "version=None Text" body)
      commitRemote root "child"
      output <- successful binary root ["upgrade", "child", "--skip-unversioned", "--json"]
      output `shouldSatisfy` T.isInfixOf "skipped"
      BS.readFile cached `shouldReturn` before
      D.doesFileExist (root </> "project/.seihou/manifest.json") `shouldReturn` False

  it "refuses a same-name base substitution before cache or project publication" $
    withSystemTempDirectory "recipe-chain-update-substitution" $ \root -> do
      binary <- remoteFixture root
      _ <- successful binary root ["install", remoteUrl "child", "--module", "child"]
      _ <- successful binary root ["run", "child"]
      before <- readManifest (root </> "project")
      cached <- BS.readFile (root </> "config/seihou/installed/base/recipe.dhall")
      let replacement = root </> "producers/replacement"
      callProcess "git" ["clone", "-q", root </> "producers/base", replacement]
      appendFile (root </> "fixture-gitconfig") ("[url \"file://" <> replacement <> "\"]\n    insteadOf = " <> remoteUrl "replacement" <> "\n")
      writeRemoteRecipe root "child" "1" "[] : List Text" (", extends=Some {recipe=\"base\",source=Some \"" <> remoteUrl "replacement" <> "\"}")
      commitRemote root "child"
      (code, output) <- invokeCommand binary root ["update", "child", "--json"]
      code `shouldBe` ExitFailure 1
      output `shouldSatisfy` T.isInfixOf "different source"
      readManifest (root </> "project") `shouldReturn` before
      BS.readFile (root </> "config/seihou/installed/base/recipe.dhall") `shouldReturn` cached

  it "rejects a base descriptor edited after the update preview without publishing" $
    withSystemTempDirectory "recipe-chain-stale-update" $ \root -> do
      binary <- fixture root
      _ <- successful binary root ["run", "child"]
      before <- readManifest root
      writeRecipe root "base" "1.1" "[\"demo\"]" ""
      let request = U.UpdateRequest (U.NamedUpdateTargets ["child"]) [] False U.ForbidPrompts RunChangedCommands False False False
      let environmentKeys = ["XDG_CONFIG_HOME", "XDG_CACHE_HOME"]
      result <- bracket (mapM lookupEnv environmentKeys) (\previous -> forM_ (zip environmentKeys previous) $ \(key, value) -> maybe (unsetEnv key) (setEnv key) value) $ \_ -> do
        setEnv "XDG_CONFIG_HOME" (root </> "config")
        setEnv "XDG_CACHE_HOME" (root </> "cache")
        D.withCurrentDirectory root $ U.withProjectUpdate request $ \case
          Left err -> pure (Left err)
          Right plan -> do
            let Just base = find ((== "base") . (^. #name)) (plan ^. #candidateArtifacts)
            TIO.appendFile (base ^. #originalDirectory </> "recipe.dhall") "\n-- changed after preview\n"
            U.applyProjectUpdate plan
      result `shouldSatisfy` (\case Left UpdatePlanStale {} -> True; _ -> False)
      readManifest root `shouldReturn` before
      readFile (root </> "command-marker") `shouldReturn` "once"

successful binary root args = do
  (code, output) <- invokeCommand binary root args
  if code == ExitSuccess then pure () else expectationFailure (T.unpack output)
  pure output

remoteUrl name = "https://example.invalid/" <> name <> ".git"

writeRemoteRecipe root name version members extra = do
  writeRecipe root name version members extra
  let target = root </> "producers" </> name
  D.createDirectoryIfMissing True target
  D.renameFile (root </> ".seihou/modules" </> name </> "recipe.dhall") (target </> "recipe.dhall")

commitRemote root name = do
  let repo = root </> "producers" </> name
  callProcess "git" ["-C", repo, "add", "."]
  callProcess "git" ["-C", repo, "-c", "user.name=Seihou Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-qm", "test: recipe candidate"]

initRemote root name = do
  callProcess "git" ["-C", root </> "producers" </> name, "init", "-q"]
  commitRemote root name

remoteFixture root = do
  binary <- fixture root
  let producers = root </> "producers"
  D.createDirectory producers
  D.createDirectory (root </> "project")
  D.renameDirectory (root </> ".seihou/modules/demo") (producers </> "demo")
  writeRemoteRecipe root "base" "1" ("[ {module=\"demo\",source=Some \"" <> remoteUrl "demo" <> "\",vars=[] : List {name : Text,value : Text}} ]") ""
  writeRemoteRecipe root "child" "1" "[] : List Text" (", extends=Some {recipe=\"base\",source=Some \"" <> remoteUrl "base" <> "\",versionRange=Some {minimum=\"1\",before=\"2\"}}")
  forM_ ["demo", "base", "child"] (initRemote root)
  -- Every URL remains portable in descriptors and manifest; only this test
  -- process's transport routes to committed fixture producers.
  -- Directory names have no .git suffix, so use one explicit rewrite per URL.
  writeFile (root </> "fixture-gitconfig") $ concat ["[url \"file://" <> producers </> name <> "\"]\n    insteadOf = " <> remoteUrl name <> "\n" | name <- ["demo", "base", "child", "fresh", "next-base"]]
  pure binary