seihou-core-0.10.0.0: src/Seihou/Core/Checkout.hs
module Seihou.Core.Checkout
( CheckoutError (..),
checkoutError,
validateCheckoutPaths,
validateCheckoutRecords,
mergeCheckoutRecord,
dropCheckoutOwners,
checkoutClaims,
checkoutSource,
replaceCheckoutApplications,
CheckoutIntent (..),
CheckoutPolicy (..),
CheckoutObservation (..),
CheckoutAction (..),
CheckoutEntry (..),
CheckoutPlan (..),
planCheckoutOutputs,
checkoutApplicable,
expectedTarget,
pathsOverlap,
)
where
import Control.Monad (foldM, forM_, unless, when)
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Maybe (isJust, maybeToList)
import Data.Set qualified as Set
import Data.Text qualified as T
import Seihou.Composition.Instance (ModuleInstance (..), qualifiedName)
import Seihou.Core.Path (validateCanonicalProjectPath)
import Seihou.Core.Types
import Seihou.Effect.Filesystem (PathEntry (..))
import Seihou.Prelude
import System.FilePath.Posix qualified as P
-- | Stable machine code and an actionable human explanation.
data CheckoutError = CheckoutError {code :: !Text, message :: !Text}
deriving stock (Eq, Show, Generic)
checkoutError :: Text -> Text -> CheckoutError
checkoutError = CheckoutError
checkoutSource :: CheckoutOutputSpec -> FilePath
checkoutSource (DirectorySymlink source) = source
pathsOverlap :: FilePath -> FilePath -> Bool
pathsOverlap a b = a == b || (a <> "/") `isPrefixOf` b || (b <> "/") `isPrefixOf` a
where
isPrefixOf x y = T.pack x `T.isPrefixOf` T.pack y
validateCheckoutPaths :: FilePath -> FilePath -> Either CheckoutError ()
validateCheckoutPaths source dest = do
forM_ [source, dest] $ \path -> first (checkoutError "checkout_path_invalid") (validateCanonicalProjectPath (T.pack path))
when (pathsOverlap source dest) $ Left (checkoutError "checkout_path_overlap" "source and destination must not overlap")
-- | Validate the entire candidate, including application-local instance ownership.
validateCheckoutRecords :: Manifest -> [FilePath] -> Either CheckoutError ()
validateCheckoutRecords manifest pending = do
let outputs = manifest ^. #checkoutOutputs
bytePaths = Map.keys (manifest ^. #files) <> Map.keys (manifest ^. #seeds) <> pending
forM_ (Map.toAscList outputs) $ \(dest, record) -> do
validateCheckoutPaths (checkoutSource (record ^. #spec)) dest
when (Map.null (record ^. #owners)) $ bad dest "has no owners"
forM_ (Map.toAscList (record ^. #owners)) $ \(appId, names) -> do
when (Set.null names) $ bad dest "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 dest "names an instance outside its owning application"
_ -> bad dest "names an unknown or duplicate application owner"
forM_ bytePaths $ \path -> when (pathsOverlap dest path) $ bad dest ("overlaps managed or seed output '" <> T.pack path <> "'; resolve the lifecycle transition manually")
forM_ (Map.keys outputs) $ \other -> do
when (dest /= other && pathsOverlap dest other) $ bad dest ("overlaps checkout destination '" <> T.pack other <> "'")
when (pathsOverlap (checkoutSource (record ^. #spec)) other) $ bad dest ("source overlaps checkout destination '" <> T.pack other <> "'")
where
bad dest reason = Left (checkoutError "checkout_declaration_invalid" ("checkout output '" <> T.pack dest <> "' " <> reason))
mergeCheckoutRecord :: FilePath -> CheckoutOutputRecord -> Map FilePath CheckoutOutputRecord -> Either CheckoutError (Map FilePath CheckoutOutputRecord)
mergeCheckoutRecord dest record records = case Map.lookup dest records of
Nothing -> Right (Map.insert dest record records)
Just old | old ^. #spec == record ^. #spec -> Right (Map.insert dest (old & #owners %~ Map.unionWith Set.union (record ^. #owners)) records)
Just _ -> Left (checkoutError "checkout_owner_conflict" ("checkout owners disagree on '" <> T.pack dest <> "'"))
-- | Replace the selected claims; preserve all unselected application/instance owners.
dropCheckoutOwners :: Map ApplicationId (Set ModuleName) -> Map FilePath CheckoutOutputRecord -> Map FilePath CheckoutOutputRecord
dropCheckoutOwners selected = Map.mapMaybe $ \record ->
let retained = Map.mapMaybeWithKey (\app names -> let rest = names `Set.difference` Map.findWithDefault Set.empty app selected in if Set.null rest then Nothing else Just rest) (record ^. #owners)
in if Map.null retained then Nothing else Just (record & #owners .~ retained)
checkoutClaims :: ApplicationId -> [Operation] -> Either CheckoutError (Map FilePath CheckoutOutputRecord)
checkoutClaims app = foldM add Map.empty
where
add records CheckoutOutputOp {dest, spec, moduleName} = mergeCheckoutRecord dest (CheckoutOutputRecord spec (Map.singleton app (Set.singleton moduleName))) records
add records _ = Right records
data CheckoutIntent = Rebuild | Reconcile deriving stock (Eq, Show, Generic)
data CheckoutPolicy = SyncIgnoredOnly | ApplyDeclaration deriving stock (Eq, Show, Generic)
data CheckoutObservation = CheckoutObservation
{ entry :: !PathEntry,
sourceDirectory :: !Bool,
problems :: ![CheckoutError],
gitAvailable :: !Bool,
ignored :: !Bool
}
deriving stock (Eq, Show, Generic)
data CheckoutAction = CreateLink | ReplaceLink | RetireLink | UnchangedLink | AlreadyAbsent | PreserveModified | RefusedLink
deriving stock (Eq, Ord, Show, Generic)
data CheckoutEntry = CheckoutEntry
{ dest :: !FilePath,
previous :: !(Maybe CheckoutOutputSpec),
candidate :: !(Maybe CheckoutOutputSpec),
observation :: !CheckoutObservation,
action :: !CheckoutAction,
errors :: ![CheckoutError],
warnings :: ![Text]
}
deriving stock (Eq, Show, Generic)
newtype CheckoutPlan = CheckoutPlan {entries :: [CheckoutEntry]}
deriving stock (Eq, Show, Generic)
checkoutApplicable :: CheckoutPlan -> Bool
checkoutApplicable = all (null . (^. #errors)) . (^. #entries)
expectedTarget :: FilePath -> CheckoutOutputSpec -> FilePath
expectedTarget dest (DirectorySymlink source) =
let parent = P.takeDirectory dest
depth = if parent == "." then 0 else length (P.splitDirectories parent)
in P.joinPath (replicate depth ".." <> [source])
planCheckoutOutputs :: CheckoutIntent -> CheckoutPolicy -> Map FilePath CheckoutOutputRecord -> Map FilePath CheckoutOutputRecord -> Map FilePath CheckoutObservation -> CheckoutPlan
planCheckoutOutputs intent policy prior candidate observations = CheckoutPlan (map plan (Set.toAscList (Map.keysSet prior <> Map.keysSet candidate)))
where
plan dest =
let old = (^. #spec) <$> Map.lookup dest prior
new = (^. #spec) <$> Map.lookup dest candidate
obs = Map.findWithDefault (CheckoutObservation EntryMissing False [checkoutError "checkout_observation_missing" "output was not observed"] False False) dest observations
unchanged = intent == Reconcile && old == new
active = isJust new
syntax = [err | spec <- maybeToList new <> maybeToList old, Left err <- [validateCheckoutPaths (checkoutSource spec) dest]]
policyErrors =
[checkoutError "checkout_git_required" "sync requires a Git working tree" | policy == SyncIgnoredOnly, not (obs ^. #gitAvailable)]
<> [checkoutError "checkout_not_ignored" "destination must be ignored; add it to .gitignore before running seihou sync" | policy == SyncIgnoredOnly, active, not (obs ^. #ignored)]
preconditions =
if unchanged
then []
else
obs ^. #problems
<> policyErrors
<> [checkoutError "checkout_source_missing" "source must be an existing real project directory; restore it before retrying" | active, not (obs ^. #sourceDirectory)]
contract = [checkoutError "checkout_rebuild_contract" "rebuild requires identical prior and candidate declarations" | intent == Rebuild, prior /= candidate]
(proposed, collisions) = classify dest old new (obs ^. #entry) unchanged
errs = contract <> syntax <> preconditions <> collisions
warnings =
["preserved a modified checkout entry; released its declaration" | proposed == PreserveModified]
<> ["Git evidence unavailable; applying a declaration before git-init" | not unchanged, policy == ApplyDeclaration, not (obs ^. #gitAvailable)]
<> ["checkout destination is not ignored; clones require ignore rules for seihou sync" | not unchanged, policy == ApplyDeclaration, obs ^. #gitAvailable, active, not (obs ^. #ignored)]
in CheckoutEntry dest old new obs (if null errs then proposed else RefusedLink) errs warnings
classify _ _ _ _ True = (UnchangedLink, [])
classify dest old Nothing entry _ = case entry of
EntryMissing -> (AlreadyAbsent, [])
EntrySymlink target | Just spec <- old, target == expectedTarget dest spec -> (RetireLink, [])
_ -> (PreserveModified, [])
classify dest old (Just spec) entry _ = case entry of
EntryMissing -> (CreateLink, [])
EntrySymlink target
| target == expectedTarget dest spec -> (UnchangedLink, [])
| intent == Rebuild || maybe False ((== target) . expectedTarget dest) old -> (ReplaceLink, [])
| otherwise -> (RefusedLink, [checkoutError "checkout_link_modified" "link target changed locally; move it aside or restore its recorded target"])
_ -> (RefusedLink, [checkoutError "checkout_destination_occupied" "destination is a real file or directory; move it aside manually"])
-- | Re-evaluation replaces a selected application's complete declaration set,
-- including dependencies that disappeared or whose conditions became false.
replaceCheckoutApplications :: Manifest -> [(AppliedComposition, [Operation])] -> Either CheckoutError Manifest
replaceCheckoutApplications prior applications = do
let selected = Set.fromList [app ^. #applicationId | (app, _) <- applications]
departing = Map.unionsWith Set.union [Map.restrictKeys (record ^. #owners) selected | record <- Map.elems (prior ^. #checkoutOutputs)]
retained = dropCheckoutOwners departing (prior ^. #checkoutOutputs)
updateApps apps (app, _) = app : filter ((/= app ^. #applicationId) . (^. #applicationId)) apps
outputs <- foldM add retained applications
let candidate = prior & #checkoutOutputs .~ outputs & #applications .~ foldl updateApps (prior ^. #applications) applications
pending =
[ dest
| (_, ops) <- applications,
op <- ops,
dest <- case op of
WriteFileOp {dest} -> [dest]
CopyFileOp {dest} -> [dest]
PatchFileOp {dest} -> [dest]
_ -> []
]
validateCheckoutRecords candidate pending
forM_ pending $ \path -> forM_ (Map.keys (prior ^. #checkoutOutputs)) $ \old ->
when (pathsOverlap path old) $ Left (checkoutError "checkout_lifecycle_transition" ("output '" <> T.pack path <> "' overlaps a previous checkout declaration; remove that declaration and resolve the transition manually"))
pure candidate
where
add records (app, ops) = do
claims <- checkoutClaims (app ^. #applicationId) ops
foldM (\acc (dest, record) -> mergeCheckoutRecord dest record acc) records (Map.toAscList claims)