packages feed

seihou-cli-0.10.0.0: src/Seihou/CLI/ManifestRepairOrigins.hs

-- | @seihou manifest repair-origins@: rewrite origins recorded as a path on
-- the machine that wrote them.
--
-- @.seihou\/manifest.json@ is checked in and must mean the same thing on every
-- machine (docs\/adr\/0001-manifest-is-a-checked-in-machine-independent-artifact.md).
-- Before plan 98, @seihou install \<local checkout\>@ stored the checkout's
-- path as the installed copy's @sourceUrl@, and every command that records
-- applied state copied it into the manifest as a 'RemoteOrigin'. Once the
-- artifact was reinstalled from its real remote, the guard reported an
-- origin mismatch that no command could clear. 'detectArtifactOrigin' no
-- longer lets a path through, but manifests written before that still hold
-- one. This command finds them, proposes a remote for each with the evidence
-- for it, and rewrites them.
--
-- Choosing a remote for a recorded path is inference, so the command is
-- explicit and prints everything it concludes, as
-- docs\/adr\/0005-legacy-manifests-convert-through-an-explicit-command.md
-- requires. It rewrites by URL rather than by record: every origin recorded
-- under one path gets the same new URL, because blueprint migration receipts
-- use the origin as part of their identity (docs\/adr\/0002) and rewriting
-- some records for a path but not others would split one artifact in two.
--
-- The core ('localOriginUrls', 'planRepair', 'applyRepair',
-- 'renderRepairOutcome') is pure. 'gatherOriginEvidence' and
-- 'runRepairOrigins' are the IO shell.
module Seihou.CLI.ManifestRepairOrigins
  ( -- * Options and outcome
    RepairOriginsOpts (..),
    RepairOutcome (..),

    -- * Sites, evidence, decisions
    SiteKind (..),
    OriginSite (..),
    OriginEvidence (..),
    RepairDecision (..),
    evidenceUrl,

    -- * Pure core
    localOriginUrls,
    validateOverrides,
    planRepair,
    applyRepair,
    overManifestOrigins,
    sameRepository,

    -- * IO shell
    gatherOriginEvidence,
    runRepairOrigins,
    renderRepairOutcome,
    handleRepairOrigins,
  )
where

import Control.Applicative ((<|>))
import Control.Monad (when)
import Data.Aeson qualified as Aeson
import Data.Aeson.Types (parseMaybe, (.:))
import Data.ByteString.Lazy qualified as LBS
import Data.Char (isDigit, toLower)
import Data.Generics.Labels ()
import Data.List (nub, nubBy)
import Data.Map.Strict qualified as Map
import Data.Maybe (mapMaybe)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Seihou.CLI.ApplicationDisplay (appliedModuleLabel, appliedTargetName, moduleNameText)
import Seihou.CLI.InstallShared (expandLocalPath)
import Seihou.Core.ArtifactIdentity (isMachineLocalOriginUrl, normalizeOriginUrl)
import Seihou.Core.ArtifactOriginDetect (readOriginInfo)
import Seihou.Core.ArtifactRef (resolveArtifactOrigin)
import Seihou.Core.Module (defaultSearchPaths)
import Seihou.Core.Types
  ( AppliedTarget (..),
    ArtifactOrigin (..),
    Manifest,
    ManifestSchemaVersion (..),
  )
import Seihou.Manifest.Types (currentManifestVersion, manifestFromJSON, manifestToJSON)
import Seihou.Prelude
import System.Directory (doesDirectoryExist, doesFileExist, getCurrentDirectory, renamePath)
import System.Exit (ExitCode (..), exitFailure)
import System.Process (readProcessWithExitCode)

-- ----------------------------------------------------------------------------
-- Types
-- ----------------------------------------------------------------------------

-- | Flags parsed for @seihou manifest repair-origins@.
data RepairOriginsOpts = RepairOriginsOpts
  { -- | Print the report without writing the manifest.
    dryRun :: !Bool,
    -- | @--set NAME=URL@, in order: the remote for the path under which the
    -- artifact @NAME@ is recorded.
    overrides :: ![(Text, Text)]
  }
  deriving stock (Eq, Show, Generic)

-- | Which of the six origin-bearing manifest records a site is.
data SiteKind
  = SiteModule
  | SiteApplicationTarget
  | SiteApplicationInstance
  | SiteRecipe
  | SiteBlueprint
  | SiteBlueprintMigration
  deriving stock (Eq, Ord, Show, Generic)

-- | One manifest record whose origin is a machine-local path.
data OriginSite = OriginSite
  { kind :: !SiteKind,
    -- | Where the record is, for the report, e.g. @modules[nix-haskell-flake]@.
    location :: !Text,
    -- | The URL exactly as recorded, before normalisation.
    recordedUrl :: !Text,
    artifactName :: !Text,
    repoName :: !(Maybe Text),
    -- | The file that makes a directory count as this kind of artifact, used
    -- to find its installed copy.
    definitionFile :: !FilePath
  }
  deriving stock (Eq, Show, Generic)

-- | Why a remote is proposed for a recorded path.
data OriginEvidence
  = -- | The recorded path is a checkout on this machine whose @origin@ remote
    -- is this URL.
    FromCheckoutRemote !Text
  | -- | The installed copy of this artifact records this URL, and its
    -- repository name agrees with the manifest's. Fields: artifact name,
    -- URL, repository name.
    FromInstalledCopy !Text !Text !(Maybe Text)
  | -- | The user passed @--set NAME=URL@. Fields: artifact name, URL.
    FromOverride !Text !Text
  deriving stock (Eq, Show, Generic)

-- | What to do about one recorded path.
data RepairDecision
  = -- | Rewrite every origin recorded under the path. Fields: the path as
    -- recorded, the new URL, the evidence, the records affected.
    Rewrite !Text !Text ![OriginEvidence] ![OriginSite]
  | -- | The evidence names more than one repository; nothing is written for
    -- this path.
    Conflicting !Text ![OriginEvidence] ![OriginSite]
  | -- | Nothing on this machine suggests a remote.
    Unresolved !Text ![OriginSite]
  deriving stock (Eq, Show, Generic)

-- | Terminal outcome of a run, decoupled from printing and exit codes so it
-- can be asserted on directly.
data RepairOutcome
  = -- | No origin is recorded as a machine-local path.
    RepairNotNeeded
  | -- | @--dry-run@: the decisions a real run would carry out.
    RepairWouldWrite ![RepairDecision]
  | -- | The manifest was rewritten according to these decisions.
    RepairWritten ![RepairDecision]
  | -- | Not a dry run, but no path had a remote to rewrite to, so the manifest
    -- was left alone.
    RepairUnwritten ![RepairDecision]
  | -- | Nothing was written; carries the message to show the user.
    RepairFailed !Text
  deriving stock (Eq, Show, Generic)

-- | The URL a piece of evidence proposes.
evidenceUrl :: OriginEvidence -> Text
evidenceUrl = \case
  FromCheckoutRemote url -> url
  FromInstalledCopy _ url _ -> url
  FromOverride _ url -> url

-- ----------------------------------------------------------------------------
-- Pure core
-- ----------------------------------------------------------------------------

-- | Every record whose origin is a 'RemoteOrigin' with a machine-local URL,
-- grouped by the normalised URL, in manifest order within each group.
localOriginUrls :: Manifest -> Map Text [OriginSite]
localOriginUrls manifest =
  Map.fromListWith
    (\later earlier -> earlier <> later)
    [ (normalizeOriginUrl (site ^. #recordedUrl), [site])
    | site <- allSites,
      isMachineLocalOriginUrl (site ^. #recordedUrl)
    ]
  where
    allSites =
      mapMaybe moduleSite (manifest ^. #modules)
        <> concatMap applicationSites (manifest ^. #applications)
        <> maybe [] (maybe [] pure . recipeSite) (manifest ^. #recipe)
        <> maybe [] (maybe [] pure . blueprintSite) (manifest ^. #blueprint)
        <> mapMaybe migrationSite (manifest ^. #blueprintMigrations)

    moduleSite applied =
      site SiteModule ("modules[" <> appliedModuleLabel applied <> "]") "module.dhall" (applied ^. #origin)

    applicationSites application =
      let targetName = appliedTargetName (application ^. #target)
          targetDefinition = case application ^. #target of
            AppliedModuleTarget _ -> "module.dhall"
            AppliedRecipeTarget _ -> "recipe.dhall"
          instanceSite state =
            site
              SiteApplicationInstance
              ( "applications["
                  <> targetName
                  <> "].instances["
                  <> moduleNameText (state ^. #name)
                  <> "]"
              )
              "module.dhall"
              (state ^. #origin)
       in maybe [] pure (site SiteApplicationTarget ("applications[" <> targetName <> "]") targetDefinition (application ^. #targetOrigin))
            <> mapMaybe instanceSite (application ^. #instances)

    recipeSite applied =
      site SiteRecipe ("recipe[" <> applied ^. #name . #unRecipeName <> "]") "recipe.dhall" (applied ^. #origin)

    blueprintSite applied =
      site SiteBlueprint ("blueprint[" <> moduleNameText (applied ^. #name) <> "]") "blueprint.dhall" (applied ^. #origin)

    migrationSite receipt =
      site
        SiteBlueprintMigration
        ( "blueprintMigrations["
            <> moduleNameText (receipt ^. #name)
            <> " "
            <> receipt ^. #fromVersion
            <> "->"
            <> receipt ^. #toVersion
            <> "]"
        )
        "blueprint.dhall"
        (receipt ^. #origin)

    site siteKind loc definition = \case
      RemoteOrigin url name repo ->
        Just
          OriginSite
            { kind = siteKind,
              location = loc,
              recordedUrl = url,
              artifactName = name,
              repoName = repo,
              definitionFile = definition
            }
      _ -> Nothing

-- | Check @--set@ overrides before anything is planned. A URL that is itself
-- a machine-local path would reproduce the problem being repaired, and a name
-- recorded under no local path is almost certainly a typo.
validateOverrides :: Map Text [OriginSite] -> [(Text, Text)] -> Either Text ()
validateOverrides sites overrides =
  case (localOverrides, unknownNames) of
    ((name, url) : _, _) ->
      Left
        ( "--set "
            <> name
            <> "="
            <> url
            <> ": "
            <> url
            <> " is a path on this machine, which is what this command removes.\n"
            <> "Give the URL other developers clone the artifact from."
        )
    ([], name : _) ->
      Left
        ( "--set "
            <> name
            <> "=...: no origin recorded as a machine-local path names "
            <> name
            <> ".\nRecorded under local paths: "
            <> T.intercalate ", " (nub [s ^. #artifactName | s <- concat (Map.elems sites)])
        )
    ([], []) -> Right ()
  where
    localOverrides = filter (isMachineLocalOriginUrl . snd) overrides
    recordedNames = [s ^. #artifactName | s <- concat (Map.elems sites)]
    unknownNames = [name | (name, _) <- overrides, name `notElem` recordedNames]

-- | Decide, for each recorded path, what to rewrite it to.
--
-- An override for any artifact recorded under the path wins over evidence.
-- Otherwise the evidence must agree on one repository ('sameRepository', so
-- an @ssh@ checkout remote and an @https@ install of the same repository
-- agree). The URL written is the installed copy's spelling when there is one,
-- because that is what the guard compares the manifest against on this
-- machine; otherwise the checkout remote's.
planRepair :: Map Text [OriginSite] -> Map Text [OriginEvidence] -> [(Text, Text)] -> [RepairDecision]
planRepair sites evidence overrides =
  [ decide key siteList
  | (key, siteList) <- Map.toList sites,
    not (null siteList)
  ]
  where
    decide key siteList =
      let old = recordedAs siteList
          names = map (^. #artifactName) siteList
          overriding = [FromOverride name url | (name, url) <- overrides, name `elem` names]
          gathered = Map.findWithDefault [] key evidence
       in case overriding of
            (chosen : _)
              | agrees overriding -> Rewrite old (evidenceUrl chosen) (overriding <> gathered) siteList
              | otherwise -> Conflicting old overriding siteList
            []
              | null gathered -> Unresolved old siteList
              | agrees gathered -> Rewrite old (preferredUrl gathered) gathered siteList
              | otherwise -> Conflicting old gathered siteList

    recordedAs siteList = case siteList of
      (first' : _) -> first' ^. #recordedUrl
      [] -> ""

    agrees items = case map evidenceUrl items of
      [] -> True
      (url : rest) -> all (sameRepository url) rest

    preferredUrl items =
      case [url | FromInstalledCopy _ url _ <- items] <> map evidenceUrl items of
        (url : _) -> url
        [] -> ""

-- | Rewrite every origin whose normalised URL is the old path of a
-- 'Rewrite'. The artifact name is kept; a missing repository name is filled
-- in from an installed copy that supplied the new URL.
applyRepair :: [RepairDecision] -> Manifest -> Manifest
applyRepair decisions = overManifestOrigins rewrite
  where
    table =
      Map.fromList
        [ (normalizeOriginUrl old, (new, repoFrom new evidence))
        | Rewrite old new evidence _ <- decisions
        ]

    repoFrom new evidence =
      case [repo | FromInstalledCopy _ url (Just repo) <- evidence, sameRepository url new] of
        (repo : _) -> Just repo
        [] -> Nothing

    rewrite origin = case origin of
      RemoteOrigin url name repo
        | isMachineLocalOriginUrl url,
          Just (new, evidenceRepo) <- Map.lookup (normalizeOriginUrl url) table ->
            RemoteOrigin new name (repo <|> evidenceRepo)
      _ -> origin

-- | Apply a function to every origin the manifest records, across all six
-- origin-bearing record kinds.
overManifestOrigins :: (ArtifactOrigin -> ArtifactOrigin) -> Manifest -> Manifest
overManifestOrigins f manifest =
  manifest
    & #modules
    . traverse
    . #origin
    %~ f
    & #applications
    . traverse
    . #targetOrigin
    %~ f
    & #applications
    . traverse
    . #instances
    . traverse
    . #origin
    %~ f
    & #recipe
    . _Just
    . #origin
    %~ f
    & #blueprint
    . _Just
    . #origin
    %~ f
    & #blueprintMigrations
    . traverse
    . #origin
    %~ f

-- | Whether two git URLs name the same repository regardless of transport:
-- @git\@github.com:o\/r.git@, @ssh:\/\/git\@github.com\/o\/r@ and
-- @https:\/\/github.com\/o\/r.git@ all agree.
sameRepository :: Text -> Text -> Bool
sameRepository left right = repositoryKey left == repositoryKey right

repositoryKey :: Text -> Text
repositoryKey raw =
  let url = normalizeOriginUrl raw
      (schemeless, hadScheme) = case T.breakOn "://" url of
        (_, rest) | not (T.null rest) -> (T.drop 3 rest, True)
        _ -> (url, False)
      withoutUser = case T.breakOn "@" schemeless of
        (_, rest)
          | not (T.null rest),
            not (T.isInfixOf "/" (T.takeWhile (/= '@') schemeless)) ->
              T.drop 1 rest
        _ -> schemeless
      (host, path) =
        if hadScheme
          then T.breakOn "/" withoutUser
          else case T.breakOn ":" withoutUser of
            (h, rest)
              | not (T.null rest),
                not (T.isInfixOf "/" h) ->
                  (h, "/" <> T.drop 1 rest)
            _ -> T.breakOn "/" withoutUser
      hostWithoutPort = case T.breakOn ":" host of
        (h, port) | not (T.null port), T.all isDigit (T.drop 1 port) -> h
        _ -> host
   in T.map toLower hostWithoutPort <> "/" <> T.dropWhile (== '/') path

-- ----------------------------------------------------------------------------
-- IO shell
-- ----------------------------------------------------------------------------

-- | For each recorded path, what this machine can observe about its remote:
-- the path's own @origin@ remote if the checkout still exists here, and the
-- remote recorded by the installed copy of each artifact recorded under it.
gatherOriginEvidence :: FilePath -> [FilePath] -> Map Text [OriginSite] -> IO (Map Text [OriginEvidence])
gatherOriginEvidence projectRoot searchPaths = traverse gatherFor
  where
    gatherFor siteList = do
      fromCheckout <- concat <$> traverse checkoutRemote (nub (map (^. #recordedUrl) siteList))
      fromInstalled <- concat <$> traverse (installedCopy siteList) (nubBy sameArtifact siteList)
      pure (fromCheckout <> fromInstalled)

    sameArtifact a b =
      a ^. #artifactName == b ^. #artifactName && a ^. #definitionFile == b ^. #definitionFile

    checkoutRemote recorded = do
      dir <- expandLocalPath recorded
      exists <- doesDirectoryExist dir
      if not exists
        then pure []
        else do
          (code, out, _) <- readProcessWithExitCode "git" ["-C", dir, "remote", "get-url", "origin"] ""
          let remote = T.strip (T.pack out)
          pure
            [ FromCheckoutRemote remote
            | code == ExitSuccess,
              not (T.null remote),
              not (isMachineLocalOriginUrl remote)
            ]

    installedCopy siteList representative = do
      let name = representative ^. #artifactName
          recordedRepos = [s ^. #repoName | s <- siteList, s ^. #artifactName == name]
      located <- resolveArtifactOrigin projectRoot searchPaths (representative ^. #definitionFile) (LocalOrigin name)
      case located of
        Left _ -> pure []
        Right dir -> do
          info <- readOriginInfo dir
          pure $ case info of
            Just origin
              | not (isMachineLocalOriginUrl (origin ^. #sourceUrl)),
                all (maybe True (\repo -> Just repo == origin ^. #repoName)) recordedRepos ->
                  [FromInstalledCopy name (origin ^. #sourceUrl) (origin ^. #repoName)]
            _ -> []

-- | Where a project's manifest lives, relative to the project root.
manifestRelativePath :: FilePath
manifestRelativePath = ".seihou" </> "manifest.json"

-- | Testable core of @seihou manifest repair-origins@: read the manifest in
-- the current directory, plan a repair for every origin recorded as a
-- machine-local path, and, unless this is a dry run, write the result.
runRepairOrigins :: RepairOriginsOpts -> IO RepairOutcome
runRepairOrigins opts = do
  projectRoot <- getCurrentDirectory
  let manifestPath = projectRoot </> manifestRelativePath
  present <- doesFileExist manifestPath
  if not present
    then
      pure
        ( RepairFailed
            ( "No "
                <> T.pack manifestRelativePath
                <> " here. Run this from the root of a project seihou has generated into."
            )
        )
    else do
      bytes <- LBS.readFile manifestPath
      case readCurrentManifest bytes of
        Left message -> pure (RepairFailed message)
        Right manifest -> do
          let sites = localOriginUrls manifest
          if Map.null sites
            then pure RepairNotNeeded
            else case validateOverrides sites (opts ^. #overrides) of
              Left message -> pure (RepairFailed message)
              Right () -> do
                searchPaths <- defaultSearchPaths
                evidence <- gatherOriginEvidence projectRoot searchPaths sites
                let decisions = planRepair sites evidence (opts ^. #overrides)
                    rewrites = [d | d@Rewrite {} <- decisions]
                case () of
                  _
                    | opts ^. #dryRun -> pure (RepairWouldWrite decisions)
                    | null rewrites -> pure (RepairUnwritten decisions)
                    | otherwise -> do
                        writeManifestAtomically manifestPath (applyRepair decisions manifest)
                        pure (RepairWritten decisions)

-- | Decode a manifest at the current schema. An older schema is refused with
-- the command that upgrades it: this command works on the decoded manifest,
-- and the upgrade to the current schema is lossless.
readCurrentManifest :: LBS.ByteString -> Either Text Manifest
readCurrentManifest bytes =
  case Aeson.eitherDecode bytes of
    Left err -> Left (unreadable (T.pack err))
    Right (Aeson.Object object)
      | Just version <- parseMaybe (.: "version") object,
        ManifestSchemaVersion version /= currentManifestVersion ->
          Left
            ( T.pack manifestRelativePath
                <> " is at schema version "
                <> T.pack (show (version :: Int))
                <> "; this command needs schema version "
                <> T.pack (show (currentManifestVersion ^. #unManifestSchemaVersion))
                <> ".\nRun 'seihou manifest upgrade' first, then run this again."
            )
    Right _ -> first (unreadable . T.pack) (manifestFromJSON bytes)
  where
    unreadable err = T.pack manifestRelativePath <> " could not be read: " <> err

-- | Write to a temporary file beside the manifest, then rename over it, so an
-- interrupted run never leaves a half-written manifest.
writeManifestAtomically :: FilePath -> Manifest -> IO ()
writeManifestAtomically manifestPath manifest = do
  let temporaryPath = manifestPath <> ".tmp"
  LBS.writeFile temporaryPath (manifestToJSON manifest)
  renamePath temporaryPath manifestPath

-- | The report printed for an outcome.
renderRepairOutcome :: RepairOutcome -> Text
renderRepairOutcome = \case
  RepairNotNeeded ->
    "✓ " <> T.pack manifestRelativePath <> " records no origin as a machine-local path; nothing to repair.\n"
  RepairWouldWrite decisions ->
    renderDecisions decisions <> "--dry-run: nothing was written.\n"
  RepairWritten decisions ->
    renderDecisions decisions
      <> "✓ Rewrote "
      <> T.pack (show (length (concat [sites | Rewrite _ _ _ sites <- decisions])))
      <> " recorded origin(s) in "
      <> T.pack manifestRelativePath
      <> ".\n  Review the diff and commit it: git diff "
      <> T.pack manifestRelativePath
      <> "\n"
  RepairUnwritten decisions ->
    renderDecisions decisions <> "No recorded path had a remote to rewrite to; nothing was written.\n"
  RepairFailed message -> message <> "\n"

renderDecisions :: [RepairDecision] -> Text
renderDecisions = T.concat . map renderDecision

renderDecision :: RepairDecision -> Text
renderDecision = \case
  Rewrite old new evidence sites ->
    T.unlines $
      [old, "  -> " <> new]
        <> map (("     evidence: " <>) . describeEvidence new) evidence
        <> ["     records: " <> describeSites sites]
  Conflicting old evidence sites ->
    T.unlines $
      [old, "  ! the evidence names different repositories; nothing is written for this path"]
        <> map (("     evidence: " <>) . describeEvidence "") evidence
        <> ["     records: " <> describeSites sites, remedy sites]
  Unresolved old sites ->
    T.unlines
      [ old,
        "  ! no remote found for this path",
        "     records: " <> describeSites sites,
        remedy sites
      ]
  where
    remedy sites =
      "     pass --set "
        <> maybe "<name>" (^. #artifactName) (headMay sites)
        <> "=<url> for the artifact recorded under this path"

    headMay = \case
      (x : _) -> Just x
      [] -> Nothing

-- | One evidence line. When the evidence proposes the URL being written, it
-- says "this remote" instead of repeating it.
describeEvidence :: Text -> OriginEvidence -> Text
describeEvidence chosen evidence = case evidence of
  FromCheckoutRemote url -> "the checkout at this path has 'origin' remote " <> remote url
  FromInstalledCopy name url _ -> "the installed copy of " <> name <> " records " <> remote url
  FromOverride name url -> "--set " <> name <> "=" <> url
  where
    remote url
      | not (T.null chosen) && normalizeOriginUrl url == normalizeOriginUrl chosen = "this remote"
      | otherwise = url

-- | The records under one path: each module, application, recipe, and
-- blueprint by name, and instances and migration receipts by count.
describeSites :: [OriginSite] -> Text
describeSites sites =
  T.intercalate ", " (named <> counted SiteApplicationInstance "application instance" <> counted SiteBlueprintMigration "blueprint migration receipt")
  where
    named = nub [site ^. #location | site <- sites, site ^. #kind `notElem` [SiteApplicationInstance, SiteBlueprintMigration]]
    counted siteKind noun =
      case length (filter ((== siteKind) . (^. #kind)) sites) of
        0 -> []
        1 -> ["1 " <> noun]
        n -> [T.pack (show n) <> " " <> noun <> "s"]

-- | Print the report and exit non-zero when anything is left for the user to
-- do: a failure, or any path that could not be resolved, even when others
-- were written.
handleRepairOrigins :: RepairOriginsOpts -> IO ()
handleRepairOrigins opts = do
  outcome <- runRepairOrigins opts
  TIO.putStr (renderRepairOutcome outcome)
  when (needsAttention outcome) exitFailure
  where
    needsAttention = \case
      RepairNotNeeded -> False
      RepairFailed _ -> True
      RepairWouldWrite decisions -> any unfinished decisions
      RepairWritten decisions -> any unfinished decisions
      RepairUnwritten decisions -> any unfinished decisions
    unfinished = \case
      Rewrite {} -> False
      Conflicting {} -> True
      Unresolved {} -> True