packages feed

seihou-cli-0.10.0.0: test/Seihou/CLI/ManifestCapabilityUpgradeSpec.hs

module Seihou.CLI.ManifestCapabilityUpgradeSpec (tests) where

import Control.Exception (bracket)
import Control.Lens ((&), (.~), (^.))
import Data.ByteString.Lazy qualified as LBS
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Data.Time (UTCTime, defaultTimeLocale, parseTimeOrError)
import Seihou.CLI.ManifestCapabilityUpgrade
import Seihou.CLI.ManifestUpgrade (ManifestUpgradeOpts (..), UpgradeOutcome (..), runManifestUpgrade)
import Seihou.CLI.UpdateSpec
  ( CoOwnerWriteMode (..),
    SharedPathFixture (..),
    installBetaVersion,
    prepareSharedPathFixture,
    publishBetaReleases,
  )
import Seihou.Core.Types
import Seihou.Manifest.Hash (hashContent)
import Seihou.Manifest.Types (emptyManifest, manifestFromJSON)
import System.Directory (doesFileExist, removeDirectoryRecursive, withCurrentDirectory)
import System.Environment (lookupEnv, setEnv, unsetEnv)
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.CLI.ManifestCapabilityUpgrade" spec

testTime :: UTCTime
testTime = parseTimeOrError True defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" "2026-07-19T00:00:00Z"

appA, appB, appC :: ApplicationId
appA = ApplicationId "a"
appB = ApplicationId "b"
appC = ApplicationId "c"

recordedApplication :: ApplicationId -> AppliedComposition
recordedApplication applicationId =
  AppliedComposition
    { applicationId,
      target = AppliedModuleTarget (ModuleName (applicationId ^. #unApplicationId)),
      targetOrigin = LocalOrigin (applicationId ^. #unApplicationId),
      targetVersion = Just "1.0.0",
      additionalModules = [],
      namespace = Nothing,
      context = Nothing,
      instances = [],
      commandReceipts = Map.empty,
      appliedAt = testTime
    }

record :: [ApplicationId] -> SharedWriteMode -> FileRecord
record owners mode =
  FileRecord (hashContent "x") "m" Template testTime Nothing (Set.fromList owners) mode

-- | A manifest whose paths cover every classification the pure certifier
-- makes. Applications a and b are recorded; c owns a path but is recorded
-- too, so only its evidence is missing; d is not recorded at all.
fixtureManifest :: Manifest
fixtureManifest =
  emptyManifest testTime
    & #applications .~ map recordedApplication [appA, appB, appC]
    & #files
      .~ Map.fromList
        [ (".gitignore", record [appA, appB] SharedWriteUnknown),
          ("flake.nix", record [appA, appB] SharedWriteUnknown),
          ("shared.txt", record [appA, appC] SharedWriteUnknown),
          ("wholefile.txt", record [appB, appC] SharedWriteUnknown),
          ("gone.txt", record [appA, appB] SharedWriteUnknown),
          ("stranger.txt", record [appA, ApplicationId "d"] SharedWriteUnknown),
          ("solo.txt", record [appA] SharedWriteUnknown),
          ("closed.txt", record [appA, appB] SharedWriteRequiresOwnershipClosure),
          ("orphan.txt", record [] SharedWriteUnknown)
        ]

append :: FilePath -> Operation
append path = PatchFileOp path "line\n" AppendLineIfAbsent Template "m"

evidence :: Map.Map ApplicationId ApplicationEvidence
evidence =
  Map.fromList
    [ ( appA,
        EvidenceOperations
          (map append [".gitignore", "flake.nix", "shared.txt", "gone.txt", "stranger.txt", "solo.txt", "closed.txt"])
          []
      ),
      ( appB,
        EvidenceOperations
          [ PatchFileOp ".gitignore" "/dist\n" AppendSection Template "m",
            WriteFileOp "flake.nix" "{}" DhallText,
            WriteFileOp "wholefile.txt" "all" Template,
            append "closed.txt"
          ]
          []
      ),
      (appC, EvidenceUnavailable "c is not installed here")
    ]

entryFor :: FilePath -> SharedWriteCertification -> Maybe SharedWriteCertificationEntry
entryFor path certification = lookup path [(entry ^. #path, entry) | entry <- certification ^. #entries]

modeIn :: FilePath -> SharedWriteCertification -> Maybe SharedWriteMode
modeIn path certification = (^. #sharedWriteMode) <$> Map.lookup path (certification ^. #manifest . #files)

spec :: Spec
spec = do
  describe "certifySharedWriteModes" $ do
    let everything = certifySharedWriteModes CertifyAllUnknownPaths fixtureManifest evidence

    it "certifies a path every owner appends to as additive-only" $
      modeIn ".gitignore" everything `shouldBe` Just SharedWriteAdditiveOnly

    it "certifies a path one owner writes wholesale as closure-required" $
      modeIn "flake.nix" everything `shouldBe` Just SharedWriteRequiresOwnershipClosure

    it "leaves a path unknown when one owner's evidence is missing" $ do
      modeIn "shared.txt" everything `shouldBe` Just SharedWriteUnknown
      (^. #gaps) <$> entryFor "shared.txt" everything
        `shouldBe` Just [OwnerEvidenceUnavailable appC "c is not installed here"]

    it "proves a closure requirement from one whole-file owner even when another is missing" $
      modeIn "wholefile.txt" everything `shouldBe` Just SharedWriteRequiresOwnershipClosure

    it "leaves a path unknown when an owner no longer writes it" $ do
      modeIn "gone.txt" everything `shouldBe` Just SharedWriteUnknown
      (^. #gaps) <$> entryFor "gone.txt" everything `shouldBe` Just [OwnerEmitsNoOperation appB]

    it "leaves a path unknown when an owner is not a recorded application" $
      (^. #gaps) <$> entryFor "stranger.txt" everything
        `shouldBe` Just [OwnerNotRecorded (ApplicationId "d")]

    it "never revisits a known mode" $ do
      modeIn "closed.txt" everything `shouldBe` Just SharedWriteRequiresOwnershipClosure
      entryFor "closed.txt" everything `shouldBe` Nothing

    it "ignores a path with no owners, which no ownership check consults" $ do
      entryFor "orphan.txt" everything `shouldBe` Nothing
      modeIn "orphan.txt" everything `shouldBe` Just SharedWriteUnknown

    it "does not change the manifest's version or anything but the modes" $ do
      (everything ^. #manifest . #version) `shouldBe` (fixtureManifest ^. #version)
      (everything ^. #manifest & #files .~ Map.empty) `shouldBe` (fixtureManifest & #files .~ Map.empty)

    it "scopes a targeted certification to paths the selection shares with others" $ do
      let targeted = certifySharedWriteModes (CertifyPathsForApplications (Set.singleton appA)) fixtureManifest evidence
          scoped = map (^. #path) (targeted ^. #entries)
      scoped `shouldContain` [".gitignore"]
      scoped `shouldNotContain` ["solo.txt"]
      scoped `shouldNotContain` ["wholefile.txt"]
      modeIn "wholefile.txt" targeted `shouldBe` Just SharedWriteUnknown
      modeIn "solo.txt" targeted `shouldBe` Just SharedWriteUnknown

  describe "certifySharedWriteModesIO" $ do
    it "certifies a schema-6 shared .gitignore from the recorded owners' installed modules" $
      withSharedFixture CoOwnerAppendsUnrecorded $ \fixture manifest -> do
        before <- TIO.readFile (fixture ^. #gitignorePath)
        certification <- certify fixture manifest
        modeIn ".gitignore" certification `shouldBe` Just SharedWriteAdditiveOnly
        TIO.readFile (fixture ^. #gitignorePath) `shouldReturn` before

    it "certifies a co-owner that writes the whole file as closure-required" $
      withSharedFixture CoOwnerWritesWholeFile $ \fixture manifest -> do
        let unknown = manifest & #files .~ Map.map (& #sharedWriteMode .~ SharedWriteUnknown) (manifest ^. #files)
        certification <- certify fixture unknown
        modeIn ".gitignore" certification `shouldBe` Just SharedWriteRequiresOwnershipClosure

    it "refuses an installed module whose version differs from the recorded one" $
      withSharedFixture CoOwnerAppendsUnrecorded $ \fixture manifest -> do
        let betaModule = installedRoot fixture </> "beta" </> "module.dhall"
        TIO.readFile betaModule >>= TIO.writeFile betaModule . T.replace "Some \"1.0.0\"" "Some \"1.1.0\""
        certification <- certify fixture manifest
        modeIn ".gitignore" certification `shouldBe` Just SharedWriteUnknown
        case (^. #gaps) <$> entryFor ".gitignore" certification of
          Just [OwnerEvidenceUnavailable owner reason] -> do
            owner `shouldBe` (fixture ^. #betaApplicationId)
            reason `shouldSatisfy` T.isInfixOf "version 1.1.0 here but the manifest records version 1.0.0"
          other -> expectationFailure ("expected one version gap, got " <> show other)

    it "certifies from the recorded release in the recorded remote once the cache has moved on" $
      withSharedFixture CoOwnerAppendsUnrecorded $ \fixture manifest -> do
        _ <- publishBetaReleases fixture ["1.0.0", "1.1.0"]
        installBetaVersion fixture "1.1.0"
        installedBefore <- TIO.readFile (fixture ^. #betaInstalledPath </> "module.dhall")
        installedOnly <- certify fixture manifest
        modeIn ".gitignore" installedOnly `shouldBe` Just SharedWriteUnknown
        (installedOnly ^. #fetchedSources) `shouldBe` []
        withSystemTempDirectory "seihou-certify-session" $ \session -> do
          fetched <- certifyWith (FetchRecordedReleases session) fixture manifest
          modeIn ".gitignore" fetched `shouldBe` Just SharedWriteAdditiveOnly
          [(source ^. #moduleName, source ^. #version) | source <- fetched ^. #fetchedSources]
            `shouldBe` [("beta", "1.0.0")]
        -- The cache is exactly as it was: nothing was swapped in.
        TIO.readFile (fixture ^. #betaInstalledPath </> "module.dhall") `shouldReturn` installedBefore

    it "keeps the gap, naming what was searched, when no commit declares the recorded version" $
      withSharedFixture CoOwnerAppendsUnrecorded $ \fixture manifest -> do
        _ <- publishBetaReleases fixture ["1.1.0"]
        installBetaVersion fixture "1.1.0"
        withSystemTempDirectory "seihou-certify-session" $ \session -> do
          certification <- certifyWith (FetchRecordedReleases session) fixture manifest
          modeIn ".gitignore" certification `shouldBe` Just SharedWriteUnknown
          case (^. #gaps) <$> entryFor ".gitignore" certification of
            Just [OwnerEvidenceUnavailable _ reason] -> do
              reason `shouldSatisfy` T.isInfixOf "version 1.1.0 here but the manifest records version 1.0.0"
              reason `shouldSatisfy` T.isInfixOf "no commit of"
              reason `shouldSatisfy` T.isInfixOf "declares beta 1.0.0"
            other -> expectationFailure ("expected one gap, got " <> show other)

    it "leaves the path unknown when a recorded owner is not installed" $
      withSharedFixture CoOwnerAppendsUnrecorded $ \fixture manifest -> do
        removeDirectoryRecursive (installedRoot fixture </> "beta")
        certification <- certify fixture manifest
        modeIn ".gitignore" certification `shouldBe` Just SharedWriteUnknown

    it "compiles without executing a module's commands" $
      withSharedFixture CoOwnerAppendsUnrecorded $ \fixture manifest -> do
        let alphaModule = installedRoot fixture </> "alpha" </> "module.dhall"
            marker = fixture ^. #projectRoot </> "command-ran"
        TIO.readFile alphaModule
          >>= TIO.writeFile alphaModule
            . T.replace
              ", commands = [] : List { run : Text, workDir : Optional Text, when : Optional Text }"
              (", commands = [{ run = \"touch " <> T.pack marker <> "\", workDir = None Text, when = None Text }]")
        certification <- certify fixture manifest
        modeIn ".gitignore" certification `shouldBe` Just SharedWriteAdditiveOnly
        doesFileExist marker `shouldReturn` False

    it "compiles with the recorded resolved values and nothing else" $
      withSharedFixture CoOwnerAppendsUnrecorded $ \fixture manifest -> do
        -- Alpha now needs a value with no default. Only the manifest's saved
        -- value can supply it; no prompt, configuration, or environment is read.
        let alphaModule = installedRoot fixture </> "alpha" </> "module.dhall"
        TIO.readFile alphaModule
          >>= TIO.writeFile alphaModule
            . T.replace
              ", vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }"
              ", vars = [{ name = \"project.name\", type = \"text\", default = None Text, description = None Text, required = True, validation = None Text }]"
        let withSaved value =
              manifest
                & #applications
                  .~ [ if application ^. #applicationId == fixture ^. #alphaApplicationId
                         then application & #instances .~ map (& #resolvedVars .~ value) (application ^. #instances)
                         else application
                     | application <- manifest ^. #applications
                     ]
        saved <- certify fixture (withSaved (Map.singleton "project.name" "demo"))
        modeIn ".gitignore" saved `shouldBe` Just SharedWriteAdditiveOnly
        unsaved <- certify fixture (withSaved Map.empty)
        modeIn ".gitignore" unsaved `shouldBe` Just SharedWriteUnknown

  describe "seihou manifest upgrade" $
    it "upgrades a schema-6 manifest to current and records the certified mode, touching only the manifest" $
      withSharedFixture CoOwnerAppendsUnrecorded $ \fixture _ -> do
        gitignoreBefore <- TIO.readFile (fixture ^. #gitignorePath)
        outcome <-
          withEnv "XDG_CONFIG_HOME" (fixture ^. #xdgHome) $
            withCurrentDirectory (fixture ^. #projectRoot) $
              runManifestUpgrade (ManifestUpgradeOpts False False Nothing)
        case outcome of
          UpgradeWritten _ -> pure ()
          other -> expectationFailure ("expected a write, got " <> show other)
        written <- LBS.readFile (fixture ^. #manifestPath)
        case manifestFromJSON written of
          Left err -> expectationFailure err
          Right upgraded -> do
            (upgraded ^. #version) `shouldBe` ManifestSchemaVersion 9
            (^. #sharedWriteMode) <$> Map.lookup ".gitignore" (upgraded ^. #files)
              `shouldBe` Just SharedWriteAdditiveOnly
        TIO.readFile (fixture ^. #gitignorePath) `shouldReturn` gitignoreBefore
  where
    installedRoot fixture = fixture ^. #xdgHome </> "seihou" </> "installed"
    certify = certifyWith InstalledReleasesOnly
    certifyWith policy fixture manifest =
      certifySharedWriteModesIO
        policy
        (fixture ^. #projectRoot)
        [installedRoot fixture]
        CertifyAllUnknownPaths
        manifest
        Map.empty

-- | Build the shared-path fixture and hand over the manifest it wrote.
withSharedFixture :: CoOwnerWriteMode -> (SharedPathFixture -> Manifest -> IO a) -> IO a
withSharedFixture mode action =
  withSystemTempDirectory "seihou-certify" $ \root -> do
    fixture <- prepareSharedPathFixture mode root
    bytes <- LBS.readFile (fixture ^. #manifestPath)
    case manifestFromJSON bytes of
      Left err -> fail err
      Right manifest -> action fixture manifest

withEnv :: String -> String -> IO a -> IO a
withEnv key value inner =
  bracket (lookupEnv key <* setEnv key value) (maybe (unsetEnv key) (setEnv key)) (const inner)