packages feed

seihou-core-0.11.0.0: src/Seihou/Engine/Seed.hs

module Seihou.Engine.Seed
  ( SeedError (..),
    SeedDecision (..),
    PlannedSeed (..),
    classifySeed,
    seedOperationClaims,
    planSeeds,
    validateSeedRun,
    executeSeeds,
    renderSeedPlan,
  )
where

import Control.Monad (foldM, forM)
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Time (UTCTime)
import Seihou.Core.Checkout (pathsOverlap)
import Seihou.Core.Path (pathsHaveConflictingSpelling, validateCanonicalProjectPath)
import Seihou.Core.Seed
import Seihou.Core.Types
import Seihou.Effect.Filesystem
import Seihou.Prelude
import System.FilePath.Posix qualified as P

data SeedError = SeedError {code :: !Text, message :: !Text}
  deriving stock (Eq, Show, Generic)

data SeedDecision = SeedWrite | SeedKeepExisting | SeedRespectDeletion | SeedAlreadySettled | SeedReleaseManaged
  deriving stock (Eq, Show, Generic)

data PlannedSeed = PlannedSeed
  { dest :: !FilePath,
    content :: !Text,
    strategy :: !Strategy,
    supplier :: !ModuleName,
    owners :: !(Set ModuleName),
    observed :: !PathEntry,
    decision :: !SeedDecision
  }
  deriving stock (Eq, Show, Generic)

classifySeed :: Maybe SeedRecord -> Maybe FileRecord -> PathEntry -> Either SeedError SeedDecision
classifySeed _ (Just _) _ = Right SeedReleaseManaged
classifySeed (Just _) Nothing EntryMissing = Right SeedRespectDeletion
classifySeed (Just _) Nothing _ = Right SeedAlreadySettled
classifySeed Nothing Nothing EntryMissing = Right SeedWrite
classifySeed Nothing Nothing EntryFile = Right SeedKeepExisting
classifySeed Nothing Nothing _ = Left (SeedError "seed_destination_occupied" "new seed destination is a symlink or directory; move it aside and retry")

seedOperationClaims :: [Operation] -> Map FilePath (Set ModuleName)
seedOperationClaims operations = Map.fromListWith Set.union [(dest, Set.singleton moduleName) | SeedFileOp {dest, moduleName} <- operations]

-- | Lifecycle transitions belong to update; force is never consent. This pure
-- preflight runs before migrations, writes, or publishing new applied metadata.
validateSeedRun :: Manifest -> [Operation] -> Either [SeedError] ()
validateSeedRun manifest operations = case errors of [] -> Right (); _ -> Left errors
  where
    errors =
      [transition path | SeedFileOp {dest = path} <- operations, Map.member path (manifest ^. #files)]
        <> [transition path | op <- operations, Just path <- [managedDestination op], seed <- Map.keys (manifest ^. #seeds), pathsOverlap path seed]
    transition path = SeedError "seed_lifecycle_transition" ("output '" <> T.pack path <> "' changes lifecycle; run seihou update for its recorded application to review the transition")

managedDestination :: Operation -> Maybe FilePath
managedDestination WriteFileOp {dest} = Just dest
managedDestination CopyFileOp {dest} = Just dest
managedDestination PatchFileOp {dest} = Just dest
managedDestination CheckoutOutputOp {dest} = Just dest
managedDestination _ = Nothing

planSeeds :: (Filesystem :> es) => FilePath -> Manifest -> Map FilePath (Set ModuleName) -> [Operation] -> Eff es (Either [SeedError] [PlannedSeed])
planSeeds root manifest claims operations = do
  results <- forM [op | op@SeedFileOp {} <- operations] $ \case
    SeedFileOp {dest, content, strategy, moduleName} -> do
      let overlaps =
            [other | other <- Map.keys (manifest ^. #files), other /= dest, (pathsOverlap dest other || pathsHaveConflictingSpelling dest other)]
              <> [other | other <- Map.keys (manifest ^. #checkoutOutputs), (pathsOverlap dest other || pathsHaveConflictingSpelling dest other)]
              <> [other | other <- Map.keys (manifest ^. #seeds), other /= dest, (pathsOverlap dest other || pathsHaveConflictingSpelling dest other)]
      case first (SeedError "seed_path_invalid") (validateCanonicalProjectPath (T.pack dest)) of
        Left err -> pure (Left err)
        Right () | not (null overlaps) -> pure (Left (SeedError "seed_path_overlap" ("seed '" <> T.pack dest <> "' overlaps retained output '" <> T.pack (head overlaps) <> "'")))
        Right () -> do
          parents <- checkParents root dest False
          case parents of
            Left err -> pure (Left err)
            Right () -> do
              observed <- inspectPath (root </> dest)
              alias <- checkSpelling root dest observed
              pure $ do
                alias
                decision <- first (atPath dest) (classifySeed (Map.lookup dest (manifest ^. #seeds)) (Map.lookup dest (manifest ^. #files)) observed)
                Right (PlannedSeed dest content strategy moduleName (Map.findWithDefault (Set.singleton moduleName) dest claims) observed decision)
    _ -> error "non-seed operation in seed partition"
  pure $ case [err | Left err <- results] of [] -> Right [planned | Right planned <- results]; errors -> Left errors

ancestors :: FilePath -> [FilePath]
ancestors path = reverse (go (P.takeDirectory path))
  where
    go "." = []; go "" = []; go p = p : go (P.takeDirectory p)

atPath :: FilePath -> SeedError -> SeedError
atPath path err = err & #message %~ (("seed '" <> T.pack path <> "': ") <>)

checkSpelling :: (Filesystem :> es) => FilePath -> FilePath -> PathEntry -> Eff es (Either SeedError ())
checkSpelling _ _ EntryMissing = pure (Right ())
checkSpelling root path _ = do
  names <- listDirectory (root </> P.takeDirectory path)
  pure $ if P.takeFileName path `elem` names then Right () else Left (SeedError "seed_native_alias" ("seed path '" <> T.pack path <> "' resolves through a different native spelling"))

-- | Never traverse an unsafe ancestor. Missing parents are created only for an
-- actual exclusive creation attempt, so respecting deletion creates no directories.
checkParents :: (Filesystem :> es) => FilePath -> FilePath -> Bool -> Eff es (Either SeedError ())
checkParents root dest create = go (ancestors dest)
  where
    go [] = pure (Right ())
    go (parent : rest) =
      inspectPath (root </> parent) >>= \case
        EntryDirectory -> do
          spelling <- checkSpelling root parent EntryDirectory
          case spelling of Left err -> pure (Left err); Right () -> go rest
        EntryMissing | create -> createDirectoryIfMissing False (root </> parent) >> go rest
        EntryMissing -> pure (Right ())
        _ -> pure (Left (SeedError "seed_parent_unsafe" ("seed ancestor '" <> T.pack parent <> "' must be a real directory; move the object aside")))

-- | Return the application's complete actual candidate claims; the caller uses
-- replaceSelectedSeedClaims after attaching final application records. No receipt
-- is returned for a failed creation. A partial run may leave a complete new file.
executeSeeds :: (Filesystem :> es) => FilePath -> ApplicationId -> UTCTime -> [PlannedSeed] -> Manifest -> Eff es (Either SeedError (Map FilePath SeedRecord))
executeSeeds root app now plans manifest = foldM apply (Right Map.empty) plans
  where
    apply (Left err) _ = pure (Left err)
    apply (Right records) planned = do
      let path = planned ^. #dest
          decision = planned ^. #decision
      outcome <- case decision of
        SeedWrite -> do
          safe <- checkParents root path True
          case safe of
            Left err -> pure (Left err)
            Right () ->
              createFileExclusive (root </> path) (planned ^. #content) >>= \case
                ExclusiveCreated -> pure (Right SeedCreated)
                ExclusiveFailure reason -> pure (Left (SeedError "seed_creation_failed" ("cannot create seed '" <> T.pack path <> "': " <> reason)))
                ExclusiveOccupied ->
                  inspectPath (root </> path) >>= \case
                    EntryFile -> pure (Right SeedFoundExisting)
                    _ -> pure (Left (SeedError "seed_destination_changed" ("seed '" <> T.pack path <> "' became occupied; move it aside and retry")))
        SeedReleaseManaged -> pure (Left (SeedError "seed_lifecycle_transition" "managed release requires seihou update"))
        SeedKeepExisting -> pure (Right SeedFoundExisting)
        _ -> pure (Right (maybe SeedFoundExisting (^. #outcome) (Map.lookup path (manifest ^. #seeds))))
      pure $ do
        actual <- outcome
        let prior = Map.lookup path (manifest ^. #seeds)
            record = SeedRecord (maybe (planned ^. #supplier) (^. #moduleName) prior) (KnownSeedOwners (Map.singleton app (planned ^. #owners))) actual (maybe now (^. #settledAt) prior)
        Right (Map.insert path record records)

renderSeedPlan :: Bool -> [PlannedSeed] -> Text
renderSeedPlan showContent plans = T.concat (map render plans)
  where
    render planned =
      "  "
        <> action (planned ^. #decision)
        <> " "
        <> T.pack (planned ^. #dest)
        <> " (seed; owned by the project)\n"
        <> if showContent && planned ^. #decision == SeedWrite then T.unlines (map ("    + " <>) (T.lines (planned ^. #content))) else ""
    action SeedWrite = "create once"
    action SeedKeepExisting = "keep existing"
    action SeedRespectDeletion = "keep deleted"
    action SeedAlreadySettled = "keep settled"
    action SeedReleaseManaged = "release to project"