packages feed

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

{-# LANGUAGE LambdaCase #-}

module Seihou.CLI.UpdateRenderSpec (tests) where

import Control.Lens ((&), (.~), (^.))
import Data.Char (isHexDigit)
import Data.Generics.Labels ()
import Data.List (isInfixOf)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as T
import Seihou.CLI.ApplicationDisplay (applicationLabel)
import Seihou.CLI.ManifestCapabilityUpgrade (CertificationGap (..), EvidenceSource (..))
import Seihou.CLI.Update (UpdateError (..), UpdateWarning (..))
import Seihou.CLI.Update.Render
  ( encodeUpdateOutput,
    errorOutput,
    errorOutputFor,
    planOutput,
    renderUpdateHuman,
  )
import Seihou.CLI.Update.Types (ApplicationRef (..), ManifestPreparation (..))
import Seihou.CLI.UpdateFixture (conflictPlan, planWithWarnings)
import Seihou.Core.Types
  ( ApplicationId (..),
    AppliedTarget (..),
    ManifestSchemaVersion (..),
    ModuleName (..),
    ParentVars (..),
    RecipeName (..),
    SharedWriteMode (..),
    VarName (..),
    emptyParentVars,
  )
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "Seihou.CLI.Update.Render" spec

spec :: Spec
spec = do
  it "groups a human plan and names unresolved paths" $ do
    let rendered = renderUpdateHuman False (planOutput conflictPlan)
    rendered `shouldSatisfy` T.isInfixOf "Files:"
    rendered `shouldSatisfy` T.isInfixOf "Conflict:    README.md"
    rendered `shouldSatisfy` T.isInfixOf "Commands:"

  it "emits one versioned JSON plan document" $ do
    let rendered = show (encodeUpdateOutput (planOutput conflictPlan))
    rendered `shouldSatisfy` isInfixOf "\\\"schemaVersion\\\":1"
    rendered `shouldSatisfy` isInfixOf "\\\"outcome\\\":\\\"plan\\\""
    rendered `shouldSatisfy` isInfixOf "\\\"classification\\\":\\\"conflict\\\""

  it "uses a stable machine error code" $ do
    let rendered = show (encodeUpdateOutput (errorOutput (UpdateManifestMissing ".seihou/manifest.json")))
    rendered `shouldSatisfy` isInfixOf "manifest_missing"
    renderUpdateHuman False (errorOutput (UpdateManifestMissing "manifest"))
      `shouldSatisfy` T.isInfixOf "Update failed [manifest_missing]"

  it "renders an expanded selection as prose, not a shown constructor" $ do
    let warning = SelectionExpandedForSharedPath ".gitignore" (moduleRef "master-plan")
        rendered = renderUpdateHuman False (planOutput (planWithWarnings [warning]))
    rendered
      `shouldSatisfy` T.isInfixOf "Warning:     also updating master-plan because it co-owns .gitignore"
    rendered `shouldNotSatisfy` T.isInfixOf "SelectionExpandedForSharedPath"

  it "names --include-shared-owners only for a known whole-file refusal" $ do
    let err =
          SharedPathRequiresApplications
            ".gitignore"
            (Set.singleton (moduleRef "nix-haskell-flake"))
            (Set.singleton (moduleRef "master-plan"))
        rendered = renderUpdateHuman False (errorOutput err)
    rendered `shouldSatisfy` T.isInfixOf "Update failed [shared_path_requires_applications]"
    rendered `shouldSatisfy` T.isInfixOf "--include-shared-owners"
    rendered `shouldSatisfy` T.isInfixOf "writes the whole file"
    rendered `shouldSatisfy` T.isInfixOf "master-plan"

  it "reports missing evidence without offering to broaden the selection" $ do
    let owner = moduleRef "master-plan"
        err =
          SharedWriteEvidenceUnavailable
            ".gitignore"
            [(owner, OwnerEvidenceUnavailable (ApplicationId "master-plan") "module master-plan 1.0.0 is not installed here")]
        rendered = renderUpdateHuman False (errorOutput err)
    rendered `shouldSatisfy` T.isInfixOf "Update failed [shared_write_evidence_unavailable]"
    rendered `shouldSatisfy` T.isInfixOf "master-plan: module master-plan 1.0.0 is not installed here"
    rendered `shouldNotSatisfy` T.isInfixOf "--include-shared-owners"

  it "names the explicit upgrade command for a legacy-schema manifest" $ do
    let rendered = renderUpdateHuman False (errorOutput (UpdateManifestUpgradeRequired ".seihou/manifest.json" (ManifestSchemaVersion 5)))
    rendered `shouldSatisfy` T.isInfixOf "Update failed [manifest_upgrade_required]"
    rendered `shouldSatisfy` T.isInfixOf "seihou manifest upgrade"

  describe "application labels" $ do
    it "names an application by its target and parent variables, never its digest" $
      applicationLabel skillRef `shouldBe` "exec-plan [skill.name=exec-plan]"

    it "sorts parent variables and names additional modules" $ do
      let ref =
            ApplicationRef
              (ApplicationId (T.replicate 64 "b"))
              (Just (AppliedRecipeTarget (RecipeName "haskell-service")))
              (ParentVars (Map.fromList [(VarName "z", "2"), (VarName "a", "1")]))
              [ModuleName "direnv", ModuleName "just"]
      applicationLabel ref `shouldBe` "haskell-service [a=1, z=2] (with direnv, just)"

    it "shows only a short digest prefix for an owner the manifest does not record" $ do
      let label = applicationLabel (ApplicationRef (ApplicationId (T.replicate 64 "c")) Nothing emptyParentVars [])
      label `shouldBe` "unrecorded application cccccccccccc"
      label `shouldNotSatisfy` leaksInternals

  describe "warnings" $ do
    it "covers every constructor" $
      all coveredWarning everyWarning `shouldBe` True

    it "renders every constructor as prose in both human and JSON output" $
      mapM_
        ( \warning -> do
            let human = renderUpdateHuman False (planOutput (planWithWarnings [warning]))
                json = T.pack (show (encodeUpdateOutput (planOutput (planWithWarnings [warning]))))
            human `shouldNotSatisfy` leaksInternals
            json `shouldNotSatisfy` T.isInfixOf "ModuleName {"
            json `shouldNotSatisfy` T.isInfixOf "CrossApplicationLastWriter"
        )
        everyWarning

    it "explains a cross-application last writer as attribution, not a content change" $ do
      let rendered =
            renderUpdateHuman
              False
              (planOutput (planWithWarnings [CrossApplicationLastWriter "ADR.md" (ModuleName "exec-plan") (ModuleName "link-skill")]))
      rendered
        `shouldSatisfy` T.isInfixOf
          "ADR.md receives content from both exec-plan and link-skill; link-skill is recorded as its last writer"
      rendered `shouldSatisfy` T.isInfixOf "not a content change"

    it "labels an expanded owner with its parent variables" $
      renderUpdateHuman False (planOutput (planWithWarnings [SelectionExpandedForSharedPath ".gitignore" skillRef]))
        `shouldSatisfy` T.isInfixOf "also updating exec-plan [skill.name=exec-plan] because it co-owns .gitignore"

  describe "closure and evidence errors" $ do
    it "lists selected and required owners by label and offers a concrete selection" $ do
      let err =
            SharedPathRequiresApplications
              ".gitignore"
              (Set.singleton (moduleRef "nix-haskell-flake"))
              (Set.singleton skillRef)
          rendered = renderUpdateHuman False (errorOutput err)
      rendered `shouldSatisfy` T.isInfixOf "Selected: nix-haskell-flake"
      rendered `shouldSatisfy` T.isInfixOf "Also required: exec-plan [skill.name=exec-plan]"
      rendered `shouldSatisfy` T.isInfixOf "(seihou update exec-plan nix-haskell-flake)"
      rendered `shouldSatisfy` T.isInfixOf "--include-shared-owners to update their full applications"
      rendered `shouldNotSatisfy` leaksInternals
      rendered `shouldNotSatisfy` T.isInfixOf "no targets"

    it "names the evidence error's repair and never offers expansion" $ do
      let err =
            SharedWriteEvidenceUnavailable
              ".gitignore"
              [(skillRef, OwnerEvidenceUnavailable (skillRef ^. #applicationId) "module exec-plan 1.2.0 is not installed here")]
          rendered = renderUpdateHuman False (errorOutput err)
      rendered `shouldSatisfy` T.isPrefixOf "Update failed [shared_write_evidence_unavailable]: Seihou has to inspect how the owners of"
      rendered `shouldSatisfy` T.isInfixOf "exec-plan [skill.name=exec-plan]: module exec-plan 1.2.0 is not installed here"
      rendered `shouldSatisfy` T.isInfixOf "Install that exact version, or make its recorded origin reachable"
      rendered `shouldSatisfy` T.isInfixOf "does not update it"
      rendered `shouldNotSatisfy` T.isInfixOf "--include-shared-owners"
      rendered `shouldNotSatisfy` leaksInternals

    it "leads the legacy-schema error with the explicit upgrade command" $
      renderUpdateHuman False (errorOutput (UpdateManifestUpgradeRequired ".seihou/manifest.json" (ManifestSchemaVersion 5)))
        `shouldSatisfy` T.isPrefixOf "Update failed [manifest_upgrade_required]: Run 'seihou manifest upgrade --dry-run'"

    it "ends every human failure with the seihou agent upgrade hint, and keeps it out of JSON" $ do
      let hint target =
            "If this keeps failing, run 'seihou agent upgrade "
              <> target
              <> "' to have an agent repair the manifest state and finish the upgrade.\n"
          failures =
            [ UpdateManifestMissing ".seihou/manifest.json",
              UpdateManifestUnreadable ".seihou/manifest.json" "bad",
              UpdateManifestUpgradeRequired ".seihou/manifest.json" (ManifestSchemaVersion 5),
              NoRecordedApplications,
              LegacyUpdateRequiresOneTarget,
              UpdateTargetNotFound "nope" ["alpha"],
              SharedPathRequiresApplications ".gitignore" Set.empty (Set.singleton skillRef),
              SharedWriteEvidenceUnavailable ".gitignore" [],
              CandidateCloneFailed "https://example.invalid/r.git" "offline",
              CandidateDowngrade "alpha" (Just "2.0.0") (Just "1.0.0"),
              UpdateConfigurationFailed "broken",
              UpdateHasUnresolvedPaths (Set.singleton "README.md"),
              UpdatePlanStale (Set.singleton "README.md"),
              UpdateCachePublicationFailed "disk full",
              UpdateManifestWriteFailed "disk full"
            ]
      mapM_
        ( \err -> do
            let rendered = renderUpdateHuman False (errorOutputFor ["nix-haskell-flake"] err)
            rendered `shouldSatisfy` T.isSuffixOf (hint "nix-haskell-flake")
            rendered `shouldSatisfy` T.isPrefixOf "Update failed ["
            renderUpdateHuman False (errorOutput err) `shouldSatisfy` T.isSuffixOf (hint "<module>")
            show (encodeUpdateOutput (errorOutputFor ["nix-haskell-flake"] err)) `shouldNotSatisfy` isInfixOf "seihou agent upgrade"
        )
        failures

    it "keeps the machine error codes stable in JSON" $
      mapM_
        ( \(err, code) ->
            show (encodeUpdateOutput (errorOutput err)) `shouldSatisfy` isInfixOf ("\\\"code\\\":\\\"" <> code <> "\\\"")
        )
        [ (SharedPathRequiresApplications ".gitignore" Set.empty (Set.singleton skillRef), "shared_path_requires_applications"),
          (SharedWriteEvidenceUnavailable ".gitignore" [], "shared_write_evidence_unavailable"),
          (UpdateManifestUpgradeRequired "m" (ManifestSchemaVersion 5), "manifest_upgrade_required")
        ]

  it "shows a staged manifest preparation in the human and JSON plan" $ do
    let preparation =
          ManifestPreparation
            { fromVersion = ManifestSchemaVersion 6,
              toVersion = ManifestSchemaVersion 7,
              modeChanges = Map.singleton ".gitignore" (SharedWriteUnknown, SharedWriteAdditiveOnly),
              evidenceSources = [],
              preparedManifest = planWithWarnings [] ^. #snapshot . #originalManifest
            }
        plan = planWithWarnings [] & #manifestPreparation .~ Just preparation
        human = renderUpdateHuman False (planOutput plan)
        json = show (encodeUpdateOutput (planOutput plan))
    human `shouldSatisfy` T.isInfixOf "Manifest:    schema 6 -> 7"
    human `shouldSatisfy` T.isInfixOf ".gitignore evidence unknown -> additive-only"
    json `shouldSatisfy` isInfixOf "\\\"fromSchema\\\":6"
    json `shouldSatisfy` isInfixOf "\\\"toSchema\\\":7"
    json `shouldSatisfy` isInfixOf "\\\"alreadyUpToDate\\\":false"
    -- Nothing was fetched, so the output is exactly what it was before.
    json `shouldNotSatisfy` isInfixOf "evidenceSources"
    human `shouldNotSatisfy` T.isInfixOf "read from"

  it "names each fetched recorded release in the human and JSON plan" $ do
    let source =
          EvidenceSource
            { moduleName = ModuleName "nix-haskell-flake",
              version = "0.13.2",
              originUrl = "https://github.com/shinzui/seihou-modules.git",
              revision = "08191d3"
            }
        preparation =
          ManifestPreparation
            { fromVersion = ManifestSchemaVersion 7,
              toVersion = ManifestSchemaVersion 7,
              modeChanges = Map.singleton ".gitignore" (SharedWriteUnknown, SharedWriteAdditiveOnly),
              evidenceSources = [source],
              preparedManifest = planWithWarnings [] ^. #snapshot . #originalManifest
            }
        plan = planWithWarnings [] & #manifestPreparation .~ Just preparation
        human = renderUpdateHuman False (planOutput plan)
        json = show (encodeUpdateOutput (planOutput plan))
    human
      `shouldSatisfy` T.isInfixOf
        "             (nix-haskell-flake 0.13.2 read from https://github.com/shinzui/seihou-modules.git at 08191d3)"
    json `shouldSatisfy` isInfixOf "\\\"evidenceSources\\\":[{"
    json `shouldSatisfy` isInfixOf "\\\"revision\\\":\\\"08191d3\\\""
    json `shouldSatisfy` isInfixOf "\\\"module\\\":\\\"nix-haskell-flake\\\""

-- | One of every warning constructor. Adding a constructor without adding
-- it here fails the exhaustiveness test below.
everyWarning :: [UpdateWarning]
everyWarning =
  [ LocalArtifactHasNoRemote "demo",
    SameVersionContentChanged "demo",
    AmbiguousLegacyValue (VarName "project.name"),
    MissingLegacyValue (VarName "project.name"),
    MigrationCommandNotSimulated (ModuleName "demo") "cabal gen-bounds",
    CrossApplicationLastWriter "agents/skills/exec-plan/ADR.md" (ModuleName "exec-plan") (ModuleName "exec-plan#bfa0a336"),
    ArbitraryCommandSideEffectsMayRemain,
    BaselinePruneFailed "permission denied",
    RecoveryCleanupDeferred "permission denied",
    SelectionExpandedForSharedPath ".gitignore" skillRef
  ]

-- | Whether a warning is one of 'everyWarning's constructors. Written as a
-- total match so GHC's incomplete-pattern warning names a new constructor.
coveredWarning :: UpdateWarning -> Bool
coveredWarning = \case
  LocalArtifactHasNoRemote {} -> True
  SameVersionContentChanged {} -> True
  AmbiguousLegacyValue {} -> True
  MissingLegacyValue {} -> True
  MigrationCommandNotSimulated {} -> True
  CrossApplicationLastWriter {} -> True
  ArbitraryCommandSideEffectsMayRemain -> True
  BaselinePruneFailed {} -> True
  RecoveryCleanupDeferred {} -> True
  CheckoutWarning {} -> True
  SelectionExpandedForSharedPath {} -> True

-- | Text that must never reach a person: Haskell constructor or record
-- syntax, or a whole application digest.
leaksInternals :: T.Text -> Bool
leaksInternals text =
  any
    (`T.isInfixOf` text)
    ["ApplicationId", "ModuleName {", "unModuleName", "CrossApplicationLastWriter", "SelectionExpandedForSharedPath", "fromList"]
    || any ((>= 64) . T.length) (T.split (not . isHexDigit) text)

skillRef :: ApplicationRef
skillRef =
  ApplicationRef
    (ApplicationId (T.replicate 64 "a"))
    (Just (AppliedModuleTarget (ModuleName "exec-plan")))
    (ParentVars (Map.fromList [(VarName "skill.name", "exec-plan")]))
    []

moduleRef :: T.Text -> ApplicationRef
moduleRef name = ApplicationRef (ApplicationId name) (Just (AppliedModuleTarget (ModuleName name))) emptyParentVars []