packages feed

seihou-cli-0.10.0.0: src/Seihou/CLI/Update/Selection.hs

module Seihou.CLI.Update.Selection
  ( MatchedApplications (..),
    SelectionPolicy (..),
    ClosureOutcome (..),
    matchApplications,
    enforceOwnershipClosure,
    applicationsWithIds,
    applicationRef,
    targetName,
    availableTargets,
  )
where

import Control.Monad (foldM)
import Data.Generics.Labels ()
import Data.List (find)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Seihou.CLI.Update.Types
import Seihou.Core.Types
import Seihou.Prelude

-- | Which recorded applications a request names, before the ownership
-- closure is consulted.
data MatchedApplications
  = -- | Every recorded application, in manifest order. Selecting all of them
    --   satisfies every closure by construction.
    MatchedAll [AppliedComposition]
  | -- | The ids a named selection matched. The closure has not been
    --   enforced yet, so this is not yet what will be updated.
    MatchedNamed (Set ApplicationId)
  | -- | A manifest with no recorded applications, seeded from one target.
    MatchedLegacy Text
  deriving stock (Eq, Show)

-- | What to do when a named selection does not satisfy the ownership closure.
data SelectionPolicy
  = -- | Refuse, and tell the user which owners are missing. The default: a
    --   named selection is never broadened without being asked.
    RequireNamedOwners
  | -- | Add the applications the closure requires, reporting each one. Chosen
    --   by @seihou update <target> --include-shared-owners@.
    IncludeSharedOwners
  deriving stock (Eq, Show)

-- | The result of enforcing the ownership closure on a named selection.
data ClosureOutcome
  = -- | The selection, possibly expanded, is safe to update, with a warning
    --   for every application the expansion added.
    ClosureSatisfied (Set ApplicationId) [UpdateWarning]
  | -- | No known closure requirement is violated, but these paths intersect
    --   the selection, leave an owner out, and record 'SharedWriteUnknown'.
    --   Their modes must be certified before the closure can be decided;
    --   selecting more applications would not supply them. The selection and
    --   warnings are those reached so far.
    ClosureNeedsEvidence (Set ApplicationId) [UpdateWarning] [FilePath]
  deriving stock (Eq, Show)

-- | Phase one: find the applications a request names, in manifest order.
-- Bare module names select every recorded application containing that
-- module instance; target names take precedence for each requested name.
--
-- This never consults shared ownership. Matching has to come first because
-- which paths' evidence matters depends on what was selected.
matchApplications :: UpdateSelection -> Manifest -> Either UpdateError MatchedApplications
matchApplications selection manifest = case selection of
  AllRecordedApplications
    | null (manifest ^. #applications) -> Left NoRecordedApplications
    | otherwise -> Right (MatchedAll (manifest ^. #applications))
  NamedUpdateTargets names
    -- A manifest with no recorded applications has no ownership to close
    -- over, so the policy cannot apply.
    | null (manifest ^. #applications) -> case nubOrd names of
        [name] -> Right (MatchedLegacy name)
        _ -> Left LegacyUpdateRequiresOneTarget
    | otherwise -> MatchedNamed <$> foldM selectName Set.empty (nubOrd names)
  where
    selectName selected name =
      let exact = filter ((== name) . targetName) (manifest ^. #applications)
          matches =
            if null exact
              then filter (containsModule name) (manifest ^. #applications)
              else exact
       in if null matches
            then Left (UpdateTargetNotFound name (availableTargets manifest))
            else Right (foldl' (flip (Set.insert . (^. #applicationId))) selected matches)

-- | Phase two: decide whether a named selection may be updated as it
-- stands.
--
-- Every managed path whose owners intersect the selection and leave an
-- owner out is judged by its recorded 'SharedWriteMode':
--
-- * 'SharedWriteAdditiveOnly' paths pass. Every owner reaches the path
--   through an additive, non-overlapping patch, so reconciling one of them
--   provably cannot disturb another.
-- * 'SharedWriteRequiresOwnershipClosure' paths refuse with
--   'SharedPathRequiresApplications', or under 'IncludeSharedOwners' pull
--   the missing owners in, iterating to a fixed point because an added
--   application may co-own another path with a third.
-- * 'SharedWriteUnknown' paths are neither: the answer has not been
--   established, so the result is 'ClosureNeedsEvidence'. Expansion is never
--   used for them, because updating whole applications does not supply a
--   missing fact.
--
-- A known refusal is reported before a request for evidence, since
-- certifying other paths could not lift it.
--
-- This is the preflight and uses only the manifest;
-- 'Seihou.Engine.Reconcile.validateOwner' checks the candidate's own
-- operations later. See
-- docs/adr/0012-an-additive-co-write-is-not-a-shared-path-conflict.md.
enforceOwnershipClosure ::
  SelectionPolicy ->
  Manifest ->
  Set ApplicationId ->
  Either UpdateError ClosureOutcome
enforceOwnershipClosure policy manifest named =
  case (closureViolations SharedWriteRequiresOwnershipClosure, closureViolations SharedWriteUnknown) of
    ((path, selectedOwners, missingOwners) : _, _) ->
      Left
        ( SharedPathRequiresApplications
            path
            (Set.map (applicationRef manifest) selectedOwners)
            (Set.map (applicationRef manifest) missingOwners)
        )
    ([], []) -> Right (ClosureSatisfied selected warnings)
    ([], unknown) -> Right (ClosureNeedsEvidence selected warnings [path | (path, _, _) <- unknown])
  where
    (selected, warnings) = case policy of
      RequireNamedOwners -> (named, [])
      IncludeSharedOwners -> expandToSharedOwners manifest named

    closureViolations mode =
      [ (path, selectedOwners, missingOwners)
      | (path, record) <- Map.toAscList (manifest ^. #files),
        record ^. #sharedWriteMode == mode,
        let selectedOwners = Set.intersection selected (record ^. #applicationIds),
        let missingOwners = (record ^. #applicationIds) Set.\\ selected,
        not (Set.null selectedOwners),
        not (Set.null missingOwners)
      ]

-- | Grow the selection until no path /known/ to require the closure leaves
-- an owner out.
--
-- For every managed path recorded 'SharedWriteRequiresOwnershipClosure'
-- whose owners intersect the selection, add all of that path's owners, to a
-- fixed point: an application pulled in through one path may co-own a
-- different path with a third application, which then has to come along too.
--
-- Additive-only paths are skipped because they do not require the closure,
-- and unknown paths because their answer is missing rather than known to
-- require it.
expandToSharedOwners :: Manifest -> Set ApplicationId -> (Set ApplicationId, [UpdateWarning])
expandToSharedOwners manifest = go []
  where
    go warnings selected =
      case [ (path, owner)
           | (path, record) <- Map.toAscList (manifest ^. #files),
             record ^. #sharedWriteMode == SharedWriteRequiresOwnershipClosure,
             not (Set.null (Set.intersection selected (record ^. #applicationIds))),
             owner <- Set.toAscList ((record ^. #applicationIds) Set.\\ selected)
           ] of
        [] -> (selected, reverse warnings)
        additions ->
          go
            ([SelectionExpandedForSharedPath path (applicationRef manifest owner) | (path, owner) <- additions] <> warnings)
            (Set.union selected (Set.fromList (map snd additions)))

-- | The recorded applications with these ids, in manifest order.
applicationsWithIds :: Manifest -> Set ApplicationId -> [AppliedComposition]
applicationsWithIds manifest ids =
  filter ((`Set.member` ids) . (^. #applicationId)) (manifest ^. #applications)

-- | A renderer-neutral reference to an application id, carrying the
-- recorded target and its root instance's parent variables when the
-- manifest records the application.
applicationRef :: Manifest -> ApplicationId -> ApplicationRef
applicationRef manifest applicationId =
  case find ((== applicationId) . (^. #applicationId)) (manifest ^. #applications) of
    Nothing -> ApplicationRef applicationId Nothing emptyParentVars []
    Just application ->
      ApplicationRef
        { applicationId,
          target = Just (application ^. #target),
          parentVars = maybe emptyParentVars (^. #parentVars) (rootInstance application),
          additionalModules = application ^. #additionalModules
        }

-- | The instance a composition was applied for: the one named after a
-- module target, or the last instance, which composition order puts after
-- its dependencies.
rootInstance :: AppliedComposition -> Maybe AppliedInstanceState
rootInstance application = case application ^. #target of
  AppliedModuleTarget name
    | Just state <- find ((== name) . (^. #name)) (reverse (application ^. #instances)) -> Just state
  _ -> lastMaybe (application ^. #instances)
  where
    lastMaybe [] = Nothing
    lastMaybe states = Just (last states)

targetName :: AppliedComposition -> Text
targetName application = case application ^. #target of
  AppliedModuleTarget name -> (name ^. #unModuleName)
  AppliedRecipeTarget name -> (name ^. #unRecipeName)

availableTargets :: Manifest -> [Text]
availableTargets manifest = nubOrd (map targetName (manifest ^. #applications) <> instanceNames)
  where
    instanceNames =
      [ state ^. #name . #unModuleName
      | application <- manifest ^. #applications,
        state <- application ^. #instances
      ]

containsModule :: Text -> AppliedComposition -> Bool
containsModule name = any ((== name) . (^. #name . #unModuleName)) . (^. #instances)

nubOrd :: (Ord a) => [a] -> [a]
nubOrd = go Set.empty
  where
    go _ [] = []
    go seen (value : rest)
      | Set.member value seen = go seen rest
      | otherwise = value : go (Set.insert value seen) rest