packages feed

okf-core-0.10.0.0: src/Okf/Profile/Bootstrap.hs

{-# LANGUAGE PackageImports #-}

-- | Render an adoption descriptor without writing the destination bundle.
module Okf.Profile.Bootstrap
  ( DescriptorImport (..),
    BootstrapError (..),
    descriptorFileName,
    descriptorImportFor,
    renderBootstrapDescriptor,
    renderBootstrapError,
    relativeImportPath,
    bootstrapOkfVersion,
  )
where

import Control.Exception (SomeAsyncException, SomeException, catch, fromException, throwIO)
import Data.Foldable (toList)
import Data.Maybe (catMaybes)
import Data.Text qualified as Text
import Dhall.Core qualified as D
import Dhall.Freeze qualified as D
import Dhall.Parser qualified as D
import Dhall.Src (Src)
import Okf.Index (OkfVersion, parseOkfVersion)
import Okf.Prelude
import Okf.Profile (ProfileSpec)
import Okf.Profile.Registry
import System.Directory (canonicalizePath)
import System.FilePath (joinPath, normalise, splitDirectories, takeDirectory, takeFileName)
import "generic-lens" Data.Generics.Labels ()

data DescriptorImport
  = ImportRegistryFile !FilePath !Text
  | ImportRegistryExpression !Text !Text
  | ImportDescriptorFile !FilePath
  deriving stock (Generic, Eq, Show)

data BootstrapError
  = RelativeExpressionImport !Text
  | DescriptorParseError !Text
  | DescriptorFreezeError !Text
  deriving stock (Generic, Eq, Show)

descriptorFileName :: FilePath
descriptorFileName = "profile.dhall"

descriptorImportFor :: ProfileSource -> Text -> DescriptorImport
descriptorImportFor source export = case source of
  RegistrySource _ (RegistryFile path) -> ImportRegistryFile path export
  RegistrySource _ (RegistryExpression expression) -> ImportRegistryExpression expression export
  DescriptorSource path -> ImportDescriptorFile path

-- | Inputs are absolute, physically resolved, normalized paths.
relativeImportPath :: FilePath -> FilePath -> FilePath
relativeImportPath base target =
  let (remainingBase, remainingTarget) = dropCommon (splitDirectories base) (splitDirectories target)
      parents = replicate (length remainingBase) ".."
   in joinPath ((if null parents then ["."] else parents) <> remainingTarget)
  where
    dropCommon (x : xs) (y : ys) | x == y = dropCommon xs ys
    dropCommon xs ys = (xs, ys)

renderBootstrapDescriptor :: FilePath -> DescriptorImport -> IO (Either BootstrapError Text)
renderBootstrapDescriptor destination source = handleFailure $ do
  base <- normalise <$> canonicalizePath destination
  prepared <- case source of
    ImportRegistryExpression expression _ -> pure $ case D.exprFromText "registry" expression of
      Left _ -> Left (DescriptorParseError "the registry expression is not valid Dhall")
      Right expr -> case concatMap relativeImports (toList expr) of
        bad : _ -> Left (RelativeExpressionImport (D.pretty bad))
        [] -> Right (expr, expression)
    ImportRegistryFile path _ -> Right <$> localExpression base path
    ImportDescriptorFile path -> Right <$> localExpression base path
  case prepared of
    Left err -> pure (Left err)
    Right (expression, reference) -> do
      frozen <- traverse (freeze base) expression
      let (export, body) = case source of
            ImportRegistryFile _ selected -> (selected, registryBody selected frozen)
            ImportRegistryExpression _ selected -> (selected, registryBody selected frozen)
            ImportDescriptorFile _ -> ("(descriptor)", frozen)
          comments =
            [ "OKF profile descriptor written by `okf profile init`.",
              "",
              "Profile: " <> export,
              "Source:  " <> reference,
              ""
            ]
              <> guidance source
      pure (Right (Text.unlines (map ("-- " <>) (concatMap (Text.splitOn "\n") comments)) <> D.pretty body <> "\n"))
  where
    handleFailure action =
      action `catch` \(err :: SomeException) ->
        case fromException err :: Maybe SomeAsyncException of
          Just _ -> throwIO err
          Nothing -> pure (Left (DescriptorFreezeError "could not prepare or freeze the descriptor; check source paths, network access, and integrity hashes"))

localExpression :: FilePath -> FilePath -> IO (D.Expr Src D.Import, Text)
localExpression base path = do
  target <- normalise <$> canonicalizePath path
  let relative = relativeImportPath base target
      pathParts = splitDirectories (takeDirectory relative)
      (prefix, directories) = case pathParts of
        ".." : rest -> (D.Parent, rest)
        "." : rest -> (D.Here, rest)
        _ -> (D.Here, pathParts)
      file = D.File (D.Directory (reverse (map Text.pack directories))) (Text.pack (takeFileName relative))
      expression = D.Embed (D.Import (D.ImportHashed Nothing (D.Local prefix file)) D.Code)
  pure (expression, D.pretty expression)

-- URL headers contain expressions outside the ordinary Expr traversal.
relativeImports :: D.Import -> [D.Import]
relativeImports imp = case D.importType (D.importHashed imp) of
  D.Local D.Here _ -> [imp]
  D.Local D.Parent _ -> [imp]
  D.Remote url -> maybe [] (concatMap relativeImports . toList) (D.headers url)
  _ -> []

freeze :: FilePath -> D.Import -> IO D.Import
freeze base imp = case D.importHashed imp of
  D.ImportHashed Nothing (D.Remote _) | D.importMode imp /= D.Location -> D.freezeRemoteImport base imp
  _ -> pure imp

registryBody :: Text -> D.Expr Src D.Import -> D.Expr Src D.Import
registryBody export expression =
  D.Let (D.makeBinding "registry" expression) $
    foldl
      (\body segment -> D.Field body (D.makeFieldSelection segment))
      (D.Var (D.V "registry" 0))
      (if Text.null export then [] else Text.splitOn "." export)

guidance :: DescriptorImport -> [Text]
guidance = \case
  ImportRegistryFile {} -> local "registry"
  ImportDescriptorFile {} -> local "profile descriptor"
  ImportRegistryExpression expression _
    | expression == defaultRegistryReference ->
        [ "The registry import is pinned by release and frozen with a sha256 integrity",
          "hash. To move to a newer release, change the tag, delete the sha256 line,",
          "and run `dhall freeze profile.dhall`. Never hand-write a sha256 value.",
          "A pin move can change what the profile demands; the okf-profiles Seihou",
          "migration blueprints in mori://shinzui/okf-profiles describe each release."
        ]
    | otherwise ->
        [ "Existing hashes are preserved and unhashed remote value imports are frozen.",
          "Local and environment inputs remain live; this is not a portable snapshot.",
          "To update remote imports, remove their hashes and run `dhall freeze profile.dhall`.",
          "Never hand-write a sha256 value."
        ]
  where
    local label =
      [ "The " <> label <> " is imported from a local path relative to this file and is not frozen.",
        "This is not a portable snapshot; loading its own imports may require network access."
      ]

renderBootstrapError :: BootstrapError -> Text
renderBootstrapError = \case
  RelativeExpressionImport imp ->
    "cwd-relative registry expression import " <> imp <> "; pass the registry as a file or directory path so okf can re-relativize it, or use an absolute path"
  DescriptorParseError message -> "descriptor parse failed: " <> message
  DescriptorFreezeError message -> "descriptor freeze failed: " <> message

bootstrapOkfVersion :: Maybe OkfVersion -> ProfileSpec -> Maybe OkfVersion
bootstrapOkfVersion existing spec = case catMaybes [existing, (spec ^. #requireBundleVersion) >>= parseOkfVersion, parseOkfVersion (spec ^. #okfVersion)] of
  [] -> Nothing
  versions -> Just (maximum versions)