seihou-core-0.11.0.0: src/Seihou/Core/Seed.hs
module Seihou.Core.Seed
( seedApplicationIds,
mergeSeedRecord,
dropSeedOwners,
seedClaimsFor,
replaceSelectedSeedClaims,
SeedOwnershipError (..),
renderSeedOwnershipError,
ManifestInvariantError (..),
renderManifestInvariantError,
validateManifestInvariants,
)
where
import Control.Monad (forM_, unless, when)
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as T
import Seihou.Composition.Instance (ModuleInstance (..), qualifiedName)
import Seihou.Core.Checkout (pathsOverlap)
import Seihou.Core.Path (pathsHaveConflictingSpelling, validateCanonicalProjectPath)
import Seihou.Core.Types
import Seihou.Prelude
seedApplicationIds :: SeedRecord -> Set ApplicationId
seedApplicationIds record = case record ^. #ownership of
LegacySeedOwners apps -> apps
KnownSeedOwners owners -> Map.keysSet owners
data SeedOwnershipError
= SeedOwnershipUnresolved FilePath (Set ApplicationId)
| SeedClaimsOutsideSelection FilePath (Set ApplicationId)
deriving stock (Eq, Show, Generic)
renderSeedOwnershipError :: SeedOwnershipError -> Text
renderSeedOwnershipError = \case
SeedOwnershipUnresolved path apps ->
"seed '"
<> T.pack path
<> "' needs exact recorded module evidence for applications "
<> T.intercalate ", " (map (^. #unApplicationId) (Set.toAscList apps))
<> "; restore access to their recorded releases and retry the command"
SeedClaimsOutsideSelection path _ -> "seed claims for '" <> T.pack path <> "' include an unselected application"
-- | Union claims, retaining the original supplier, outcome and time. Callers
-- validate the combined manifest after attaching its recorded applications.
mergeSeedRecord :: FilePath -> SeedRecord -> Manifest -> Either SeedOwnershipError Manifest
mergeSeedRecord path receipt manifest = do
combined <- case Map.lookup path (manifest ^. #seeds) of
Nothing -> Right receipt
Just old -> do
owners <- case (old ^. #ownership, receipt ^. #ownership) of
(KnownSeedOwners a, KnownSeedOwners b) -> Right (KnownSeedOwners (Map.unionWith Set.union a b))
(LegacySeedOwners a, LegacySeedOwners b) -> Right (LegacySeedOwners (a <> b))
_ -> Left (SeedOwnershipUnresolved path (seedApplicationIds old <> seedApplicationIds receipt))
Right (old & #ownership .~ owners)
pure (manifest & #seeds %~ Map.insert path combined)
dropSeedOwners :: Map ApplicationId (Set ModuleName) -> FilePath -> Manifest -> Either SeedOwnershipError Manifest
dropSeedOwners departing path manifest = case Map.lookup path (manifest ^. #seeds) of
Nothing -> Right manifest
Just receipt -> do
retained <- case receipt ^. #ownership of
LegacySeedOwners apps
| Set.null (apps `Set.intersection` Map.keysSet departing) -> Right (Just receipt)
| otherwise -> Left (SeedOwnershipUnresolved path apps)
KnownSeedOwners owners ->
let rest = Map.mapMaybeWithKey (\app names -> nonempty (names `Set.difference` Map.findWithDefault Set.empty app departing)) owners
in Right (if Map.null rest then Nothing else Just (receipt & #ownership .~ KnownSeedOwners rest))
pure (manifest & #seeds %~ Map.update (const retained) path)
seedClaimsFor :: ApplicationId -> ModuleName -> Manifest -> Either SeedOwnershipError (Map FilePath SeedRecord)
seedClaimsFor app name manifest = Map.traverseMaybeWithKey select (manifest ^. #seeds)
where
select path receipt = case receipt ^. #ownership of
LegacySeedOwners apps
| Set.member app apps -> Left (SeedOwnershipUnresolved path apps)
| otherwise -> Right Nothing
KnownSeedOwners owners -> Right (if Set.member name (Map.findWithDefault Set.empty app owners) then Just receipt else Nothing)
-- | Replace the complete claim set of selected applications in one pass.
-- A surviving receipt keeps its original settlement even when every old
-- selected claim was replaced. Unknown unselected claims are preserved.
replaceSelectedSeedClaims :: Set ApplicationId -> Map FilePath SeedRecord -> Manifest -> Either SeedOwnershipError Manifest
replaceSelectedSeedClaims selected incoming manifest = do
forM_ (Map.toAscList incoming) $ \(path, receipt) -> do
unless (seedApplicationIds receipt `Set.isSubsetOf` selected) $
Left (SeedClaimsOutsideSelection path (seedApplicationIds receipt Set.\\ selected))
case receipt ^. #ownership of
LegacySeedOwners apps -> Left (SeedOwnershipUnresolved path apps)
KnownSeedOwners _ -> Right ()
records <- Map.traverseMaybeWithKey replace (Map.union (manifest ^. #seeds) incoming)
pure (manifest & #seeds .~ records)
where
replace path _ = case Map.lookup path (manifest ^. #seeds) of
Nothing -> Right (Map.lookup path incoming)
Just old -> case old ^. #ownership of
LegacySeedOwners apps
| not (Set.null (apps `Set.intersection` selected)) || Map.member path incoming -> Left (SeedOwnershipUnresolved path apps)
| otherwise -> Right (Just old)
KnownSeedOwners owners ->
let proposed = case (^. #ownership) <$> Map.lookup path incoming of
Just (KnownSeedOwners claims) -> claims
_ -> Map.empty
rest = Map.unionWith Set.union (Map.withoutKeys owners selected) proposed
in Right (if Map.null rest then Nothing else Just (old & #ownership .~ KnownSeedOwners rest))
nonempty :: Set a -> Maybe (Set a)
nonempty names = if Set.null names then Nothing else Just names
data ManifestInvariantError
= PathBothManagedAndSeeded FilePath
| InvalidSeedPath FilePath Text
| SeedPathOverlap FilePath FilePath
| InvalidSeedOwners FilePath Text
deriving stock (Eq, Show, Generic)
renderManifestInvariantError :: ManifestInvariantError -> Text
renderManifestInvariantError = \case
PathBothManagedAndSeeded path -> "path " <> T.pack path <> " is recorded both as a tracked file and as a seed"
InvalidSeedPath path reason -> "seed '" <> T.pack path <> "': " <> reason
SeedPathOverlap path other -> "seed '" <> T.pack path <> "' overlaps output '" <> T.pack other <> "'"
InvalidSeedOwners path reason -> "seed '" <> T.pack path <> "' " <> reason
validateManifestInvariants :: Manifest -> Either ManifestInvariantError ()
validateManifestInvariants manifest = do
forM_ (Map.keys (Map.intersection (manifest ^. #files) (manifest ^. #seeds))) $ \path -> Left (PathBothManagedAndSeeded path)
forM_ (Map.toAscList (manifest ^. #seeds)) $ \(path, receipt) -> case receipt ^. #ownership of
-- Preserve historical receipts without pretending they contain exact facts.
LegacySeedOwners _ -> Right ()
KnownSeedOwners owners -> do
first (InvalidSeedPath path) (validateCanonicalProjectPath (T.pack path))
when (Map.null owners) $ bad path "has no owners"
forM_ (Map.toAscList owners) $ \(appId, names) -> do
when (Set.null names) $ bad path "has an empty instance owner set"
case filter ((== appId) . (^. #applicationId)) (manifest ^. #applications) of
[app] -> unless (names `Set.isSubsetOf` Set.fromList [qualifiedName (ModuleInstance (state ^. #name) (state ^. #parentVars)) | state <- app ^. #instances]) $ bad path "names an instance outside its owning application"
_ -> bad path "names an unknown or duplicate application owner"
forM_ (Map.keys (manifest ^. #files) <> Map.keys (manifest ^. #checkoutOutputs) <> filter (/= path) (Map.keys (manifest ^. #seeds))) $ \other ->
when (pathsOverlap path other || pathsHaveConflictingSpelling path other) $ Left (SeedPathOverlap path other)
where
bad path = Left . InvalidSeedOwners path