packages feed

seihou-core-0.6.0.0: src/Seihou/Core/ArtifactRef.hs

-- | Turn an artifact origin recorded in the manifest into a directory on
-- this machine.
--
-- @.seihou\/manifest.json@ is checked into version control and records no
-- absolute path (see
-- docs\/adr\/0001-manifest-is-a-checked-in-machine-independent-artifact.md),
-- so every command that needs an artifact's bytes has to ask this question
-- first. This module is the single place that answers it, and the single
-- place that phrases the answer when it is "not here".
module Seihou.Core.ArtifactRef
  ( ArtifactRefError (..),
    resolveArtifactOrigin,
    renderArtifactRefError,
  )
where

import Data.Generics.Labels ()
import Data.Text qualified as T
import Seihou.Core.Types (ArtifactOrigin (..))
import Seihou.Prelude
import System.Directory (doesFileExist)
import System.FilePath (joinPath)

-- | Why an origin recorded in the manifest could not be turned into a
-- directory on this machine.
data ArtifactRefError
  = -- | Nothing named by the origin exists in any search path. Carries the
    -- origin and the exact directories that were probed, in order.
    ArtifactNotFoundLocally !ArtifactOrigin ![FilePath]
  | -- | A 'ProjectOrigin' pointed at a path inside the project that does
    -- not exist. Carries the origin and the absolute path that was tried.
    ProjectArtifactMissing !ArtifactOrigin !FilePath
  deriving stock (Eq, Show, Generic)

-- | Turn a recorded origin into the absolute directory on this machine
-- that holds the artifact's definition file.
--
-- @projectRoot@ is the absolute directory containing @.seihou@.
-- @searchPaths@ is normally 'Seihou.Core.Module.defaultSearchPaths'; it is
-- a parameter so tests can supply temporary directories.
-- @definitionFile@ is the file that must be present for a directory to
-- count as the artifact — @"module.dhall"@ for modules,
-- @"recipe.dhall"@ for recipes, @"blueprint.dhall"@ for blueprints.
--
-- A 'ProjectOrigin' resolves against the project root and nowhere else. If
-- the recorded directory is absent the repository is incomplete, and
-- quietly substituting a globally installed artifact of the same name would
-- be exactly the invisible substitution the portable manifest exists to
-- prevent.
--
-- A 'RemoteOrigin' or 'LocalOrigin' resolves by name through @searchPaths@
-- in the ordinary discovery order, so a developer who deliberately shadows
-- an installed module with a project-local copy keeps that shadowing.
-- Whether what was found actually matches the recorded origin is a separate
-- question, answered by
-- docs\/plans\/78-refuse-accidental-module-downgrades-and-origin-mismatches.md.
resolveArtifactOrigin ::
  FilePath ->
  [FilePath] ->
  FilePath ->
  ArtifactOrigin ->
  IO (Either ArtifactRefError FilePath)
resolveArtifactOrigin projectRoot searchPaths definitionFile origin = case origin of
  ProjectOrigin relative -> do
    let candidate = projectRoot </> fromPortablePath relative
    present <- hasDefinition candidate
    pure $
      if present
        then Right candidate
        else Left (ProjectArtifactMissing origin candidate)
  RemoteOrigin _ artifact _ -> searchByName artifact
  LocalOrigin artifact -> searchByName artifact
  where
    searchByName artifact = do
      let candidates = [dir </> T.unpack artifact | dir <- searchPaths]
      found <- firstPresent candidates
      pure (maybe (Left (ArtifactNotFoundLocally origin candidates)) Right found)

    firstPresent [] = pure Nothing
    firstPresent (candidate : rest) = do
      present <- hasDefinition candidate
      if present then pure (Just candidate) else firstPresent rest

    hasDefinition directory = doesFileExist (directory </> definitionFile)

-- | The manifest stores project-relative paths with forward slashes so a
-- manifest written on Windows matches one written on POSIX. Turn one back
-- into a native path.
fromPortablePath :: FilePath -> FilePath
fromPortablePath = joinPath . filter (not . null) . splitOnSlash
  where
    splitOnSlash path = case break (== '/') path of
      (segment, []) -> [segment]
      (segment, _ : rest) -> segment : splitOnSlash rest

-- | Render a resolution failure as the multi-line message the user sees.
--
-- Callers prepend their own one-line context ("cannot plan a migration",
-- "cannot regenerate"); the body below is identical everywhere so a reader
-- who has seen it once recognises it.
renderArtifactRefError :: ArtifactRefError -> Text
renderArtifactRefError (ProjectArtifactMissing origin candidate) =
  T.intercalate
    "\n"
    [ "Artifact '" <> artifactOriginLabel origin <> "' is recorded in .seihou/manifest.json",
      "as living inside this project, but the directory is missing.",
      "",
      "  Expected at: " <> T.pack candidate,
      "",
      "This directory should be committed alongside the manifest. Restore it",
      "from version control, or re-run the module that creates it."
    ]
renderArtifactRefError (ArtifactNotFoundLocally origin candidates) =
  T.intercalate "\n" (header <> [""] <> recordedOrigin <> searched <> [""] <> remedy)
  where
    label = artifactOriginLabel origin

    header =
      [ "Artifact '" <> label <> "' is recorded in .seihou/manifest.json but is not",
        "installed on this machine."
      ]

    recordedOrigin = case origin of
      RemoteOrigin url _ _ -> ["  Recorded origin: " <> url, ""]
      _ -> []

    searched = "  Searched:" : ["    " <> T.pack candidate | candidate <- candidates]

    remedy = case origin of
      RemoteOrigin url _ _ ->
        [ "  Install it with:",
          "    seihou install " <> url
        ]
      _ ->
        [ "  This artifact has no recorded upstream, so seihou cannot fetch it.",
          "  Place a copy in one of the directories above."
        ]

-- | The name to show a user for an origin.
artifactOriginLabel :: ArtifactOrigin -> Text
artifactOriginLabel (RemoteOrigin _ artifact _) = artifact
artifactOriginLabel (LocalOrigin artifact) = artifact
artifactOriginLabel (ProjectOrigin relative) = T.pack relative