packages feed

seihou-core-0.14.0.0: src/Seihou/Composition/RecipeChain.hs

-- | Offline discovery of a recipe chain and its member origins. Acquisition
-- belongs to install/update; this loader never fetches or changes the cache.
module Seihou.Composition.RecipeChain (loadRecipeChain, recordRecipeChain) where

import Control.Lens ((^.))
import Control.Monad (forM, unless, when)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Except (ExceptT (..), runExceptT)
import Data.Generics.Labels ()
import Data.Text qualified as T
import Seihou.Composition.Recipe
import Seihou.Composition.Resolve (verifyDependencySources)
import Seihou.Core.ArtifactIdentity (normalizeOriginUrl, sameArtifactIdentity)
import Seihou.Core.ArtifactOriginDetect (detectArtifactOrigin, readOriginInfo)
import Seihou.Core.Module (discoverModule, discoverRunnable)
import Seihou.Core.Recipe (validateRecipe, validateRecipeVersion)
import Seihou.Core.Types
import Seihou.Dhall.Eval (normalizeDhallFile)
import Seihou.Manifest.Hash (hashContent)
import System.FilePath ((</>))

loadRecipeChain :: FilePath -> [FilePath] -> Recipe -> FilePath -> IO (Either ModuleLoadError ExpandedRecipe)
loadRecipeChain projectRoot paths target targetDir = do
  result <- runExceptT $ do
    nodes <- visit [] target targetDir
    ExceptT $ pure $ either (Left . ValidationError targetName) Right (mergeRecipeChain nodes)
  case result of
    Right expanded -> pure (Right expanded)
    Left err -> do
      info <- readOriginInfo targetDir
      let repair = case info of
            Just origin -> "Retry: seihou install " <> origin ^. #sourceUrl <> " --module " <> target ^. #name . #unRecipeName
            Nothing -> "Restore the required local recipes and members, or install the derived recipe from its declaring registry."
      pure (Left (ValidationError targetName ["Recipe '" <> target ^. #name . #unRecipeName <> "': " <> describe err, repair]))
  where
    targetName = ModuleName (target ^. #name . #unRecipeName)
    failure :: T.Text -> ExceptT ModuleLoadError IO a
    failure msg = ExceptT (pure (Left (ValidationError targetName [msg])))
    visit active descriptor dir = do
      _ <- ExceptT $ pure $ either (Left . ValidationError targetName) Right (validateRecipe descriptor)
      origin <- liftIO (detectArtifactOrigin projectRoot dir)
      let name = descriptor ^. #name
      when (any (\(n, o) -> n == name && sameArtifactIdentity o origin) active) $
        failure ("Recipe base cycle: " <> T.intercalate " -> " (map ((^. #unRecipeName) . fst) (reverse active) <> [name ^. #unRecipeName]))
      parents <- case descriptor ^. #extends of
        Nothing -> pure []
        Just ref -> do
          foundResult <- liftIO (discoverRunnable paths (ModuleName (ref ^. #recipe . #unRecipeName)))
          found <- case foundResult of
            Left err -> failure ("Base chain " <> T.intercalate " -> " (map ((^. #unRecipeName) . fst) (reverse ((name, origin) : active)) <> [ref ^. #recipe . #unRecipeName]) <> ": " <> describe err)
            Right value -> pure value
          case found of
            RunnableRecipe base baseDir -> do
              case ref ^. #source of
                Nothing -> pure ()
                Just required -> do
                  actual <- liftIO (readOriginInfo baseDir)
                  unless (maybe False (\info -> normalizeOriginUrl (info ^. #sourceUrl) == normalizeOriginUrl required) actual) $
                    failure ("Base '" <> ref ^. #recipe . #unRecipeName <> "' requires " <> required <> "; resolved origin is different or unknown")
              case validateRecipeVersion ref base of
                Left errors -> failure ("Base '" <> ref ^. #recipe . #unRecipeName <> "' from " <> maybe "local discovery" id (ref ^. #source) <> ": " <> T.intercalate "; " errors)
                Right () -> pure ()
              visit ((name, origin) : active) base baseDir
            _ -> failure ("Expected recipe base '" <> ref ^. #recipe . #unRecipeName <> "'")
      ExceptT (verifyDependencySources paths dir (descriptor ^. #modules))
      members <- forM (descriptor ^. #modules) $ \dep -> do
        memberDir <- ExceptT (discoverModule paths (dep ^. #module_))
        memberOrigin <- liftIO (detectArtifactOrigin projectRoot memberDir)
        pure (ResolvedRecipeMember dep name origin dir memberOrigin)
      pure (parents <> [ResolvedRecipeNode descriptor origin dir members])

-- Keep local discovery failures useful without exposing Haskell constructors.
describe :: ModuleLoadError -> T.Text
describe (ModuleNotFound (ModuleName name) _) = "Required recipe or member '" <> name <> "' was not found locally"
describe (DhallEvalError _ reason) = "Cannot evaluate descriptor: " <> reason
describe (DhallDecodeError _ reason) = "Cannot decode descriptor: " <> reason
describe (ValidationError _ reasons) = T.intercalate "; " reasons
describe (CircularDependency names) = "Dependency cycle: " <> T.intercalate " -> " [name | ModuleName name <- names]
describe (MissingSourceFile (ModuleName name) path) = "Member '" <> name <> "' is missing " <> T.pack path
describe (RegistryEvalError source reason) = "Cannot evaluate registry '" <> source <> "': " <> reason

-- | Descriptor evidence is normalized Dhall, independent of import/cache paths.
recordRecipeChain :: ExpandedRecipe -> IO [AppliedRecipeNode]
recordRecipeChain expanded = forM (expanded ^. #chain) $ \node -> do
  canonical <- normalizeDhallFile (node ^. #directory </> "recipe.dhall")
  pure (AppliedRecipeNode (node ^. #definition . #name) (node ^. #origin) (node ^. #definition . #version) (hashContent canonical))