packages feed

salmon-ops-0.2.0.0: src/Salmon/Actions/Query.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}

{- | Targeting 'Salmon.Actions.UpDown.upTree' (and 'run tree'/'run dag') at a
subset of an already-expanded graph, per @specs/advance-querying.md@.

A node is addressed by its tree /position/ (the same @\/initialize\/chown@
paths 'Salmon.Actions.Help.printHelpCograph' already prints), not by
identity: the same 'Ref' can occur at several paths (a shared predecessor,
e.g. a directory two files sit in). Resolving a selector is therefore always
"match paths, then take the 'Ref' at each match" — see 'resolveSelectors'.

The paths themselves are never listed to do that. The expanded graph is a
/tree/: a shared predecessor is a whole subtree again under each of its
parents, so the number of paths to a node doubles with every diamond above
it. Everything here that must answer on any graph goes through an 'Outline'
instead — the same graph with one entry per 'Ref' — and only 'pathedNodes',
which is asked for exactly that listing, still walks every occurrence.
-}
module Salmon.Actions.Query (
    -- * Patterns
    PatternSegment (..),
    parsePattern,
    matchPattern,

    -- * One entry per node
    Outline,
    outline,
    outlineRefs,
    outlineRoots,
    outlineChildren,
    outlineFirstPaths,
    matchOutline,
    pathLimit,
    pathCap,
    ListedPath (..),
    outlinePaths,
    renderListedPath,

    -- * Resolving selectors against an expanded graph
    pathedRefs,
    pathedNodes,
    resolveSelectors,
    resolveRewrittenSelectors,

    -- * Applying an exclusion set
    forceSkip,

    -- * Human-readable output
    shortRef, -- re-exported from "Salmon.Op.Ref", where it lives
    renderAnnotated,
    printAnnotated,

    -- * Plans
    Plan (..),
    digestBytes,
) where

import Control.Comonad.Cofree (Cofree (..))
import Data.Aeson (FromJSON, ToJSON)
import qualified Data.ByteString as ByteString
import qualified Data.ByteString.Lazy as LByteString
import qualified Crypto.Hash.SHA256 as SHA256
import Data.Foldable (toList, traverse_)
import qualified Data.List as List
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Set (Set)
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.IO as Text
import GHC.Generics (Generic)
import Numeric (showHex)

import Salmon.Actions.Help (pathText)
import Salmon.Builtin.Extension (Extension (..), Op)
import Salmon.Op.Actions
import Salmon.Op.Graph (Graph)
import qualified Salmon.Op.Dag as Dag
import Salmon.Op.OpGraph
import Salmon.Op.Ref (Ref, shortRef, unRef)
import Salmon.Op.Rewrite (Rewritten)
import qualified Salmon.Op.Rewrite as Rewrite
import Salmon.Actions.UpDown (CheckResult (Skipped))

-------------------------------------------------------------------------------

-- | One segment of a parsed selector pattern.
data PatternSegment
    = Lit Text
    | -- | @*@: exactly one segment.
      Star
    | -- | @**@: zero or more segments (any depth, including none).
      DoubleStar
    deriving (Show, Eq)

-- | Parses a @\/a\/b\/*\/**@-style pattern into 'PatternSegment's.
parsePattern :: Text -> [PatternSegment]
parsePattern raw =
    map toSegment $ filter (not . Text.null) $ Text.splitOn "/" raw
  where
    toSegment "*" = Star
    toSegment "**" = DoubleStar
    toSegment s = Lit s

-- | Does this parsed pattern match this node path (both as segment lists)?
matchPattern :: [PatternSegment] -> [Text] -> Bool
matchPattern [] [] = True
matchPattern [] (_ : _) = False
matchPattern (DoubleStar : ps) path =
    matchPattern ps path || case path of
        [] -> False
        (_ : rest) -> matchPattern (DoubleStar : ps) rest
matchPattern (_ : _) [] = False
matchPattern (Star : ps) (_ : rest) = matchPattern ps rest
matchPattern (Lit l : ps) (seg : rest) = l == seg && matchPattern ps rest

-------------------------------------------------------------------------------

{- | An expanded graph with one entry per 'Ref': what a node is called, the
nodes directly under it, and the path it was first met at.

Building it ('outline') descends into a 'Ref' the first time it is met and
not again, which is what keeps it linear in nodes and edges where the tree
it is read from is exponential in the number of diamonds. A later occurrence
is looked at one level deep: a node directly under it that was not under
this 'Ref' before is added and walked in turn, which is enough for a node
declared a second time with one more dependency. What it does not see is an
occurrence whose own dependencies are the ones already known but whose
dependencies' subtrees differ from their first occurrence; and a 'Ref' met
under two shorthands keeps the first.
-}
data Outline = Outline
    { outlineEntries :: !(Map Ref Entry)
    , outlineRootsRev :: ![Ref]
    -- ^ the nodes with no 'Ref'-carrying node above them, last met first
    , outlineRootSet :: !(Set Ref)
    -- ^ the same, as a set: what asking whether a node is a root reads, so
    -- that a graph with many roots is not searched once per root.
    , outlineOrderRev :: ![Ref]
    -- ^ every node, last met first
    }

data Entry = Entry
    { entryShorthand :: !Text
    , entryHelp :: !Text
    , entryFirstPath :: ![Text]
    , entryChildrenRev :: ![Ref]
    , entryChildSet :: !(Set Ref)
    -- ^ the same, as a set: what asking whether a node is already under
    -- this one reads, so that a node with many children (a fan) is not
    -- searched once per child.
    }

-- | See 'Outline'.
outline :: Cofree Graph Op -> Outline
outline = go Nothing [] (Outline Map.empty [] Set.empty [])
  where
    -- the node above (if any), the path to it, and what has been seen so far.
    go :: Maybe Ref -> [Text] -> Outline -> Cofree Graph Op -> Outline
    go parent pfx acc (x :< g) =
        case x.node of
            Actionless -> List.foldl' (go parent pfx) acc (toList g)
            Actions act ->
                let ref = act.extension.ref
                 in case Map.lookup ref acc.outlineEntries of
                        Nothing ->
                            let path = pfx <> [shorthand act]
                                entered =
                                    acc
                                        { outlineEntries =
                                            Map.insert ref (Entry (shorthand act) act.extension.help path [] Set.empty) acc.outlineEntries
                                        , outlineOrderRev = ref : acc.outlineOrderRev
                                        }
                             in List.foldl' (go (Just ref) path) (link parent ref entered) (toList g)
                        Just e ->
                            List.foldl' (peek ref e.entryFirstPath) (link parent ref acc) (toList g)

    -- a later occurrence of @ref@: only what is new directly under it is walked.
    peek :: Ref -> [Text] -> Outline -> Cofree Graph Op -> Outline
    peek ref path acc c@(x :< g) =
        case x.node of
            Actionless -> List.foldl' (peek ref path) acc (toList g)
            Actions act
                | linked (Just ref) act.extension.ref acc -> acc
                | otherwise -> go (Just ref) path acc c

    linked :: Maybe Ref -> Ref -> Outline -> Bool
    linked Nothing ref acc = ref `Set.member` acc.outlineRootSet
    linked (Just parent) ref acc =
        maybe False (Set.member ref . (.entryChildSet)) (Map.lookup parent acc.outlineEntries)

    link :: Maybe Ref -> Ref -> Outline -> Outline
    link parent ref acc
        | linked parent ref acc = acc
        | otherwise = case parent of
            Nothing -> acc{outlineRootsRev = ref : acc.outlineRootsRev, outlineRootSet = Set.insert ref acc.outlineRootSet}
            Just p ->
                acc{outlineEntries = Map.adjust (\e -> e{entryChildrenRev = ref : e.entryChildrenRev, entryChildSet = Set.insert ref e.entryChildSet}) p acc.outlineEntries}

-- | Every node of the graph.
outlineRefs :: Outline -> Set Ref
outlineRefs = Map.keysSet . (.outlineEntries)

-- | The nodes with no node above them, in the order they were first met.
outlineRoots :: Outline -> [Ref]
outlineRoots o = reverse o.outlineRootsRev

-- | The nodes directly under a node, in the order they were first met under
-- it; nothing for a 'Ref' the outline does not hold.
outlineChildren :: Outline -> Ref -> [Ref]
outlineChildren o ref = maybe [] (reverse . (.entryChildrenRev)) (Map.lookup ref o.outlineEntries)

{- | Each node once, in the order and at the path a walk of the whole tree
first meets it, with its help text: 'pathedNodes' keeping the first entry
per 'Ref', without listing the others.
-}
outlineFirstPaths :: Outline -> [([Text], Ref, Text)]
outlineFirstPaths o =
    [ (e.entryFirstPath, ref, e.entryHelp)
    | ref <- reverse o.outlineOrderRev
    , Just e <- [Map.lookup ref o.outlineEntries]
    ]

{- | The nodes reached by at least one path the pattern matches: what
filtering 'pathedRefs' with 'matchPattern' gives, without the listing.

The pattern is read as an automaton whose states are its suffixes, and the
graph is searched for the (node, suffix) pairs that can occur, each visited
once — so the cost is nodes and edges times the pattern's length, however
many paths there are.
-}
matchOutline :: [PatternSegment] -> Outline -> Set Ref
matchOutline pat o = go Set.empty Set.empty [(r, i) | r <- o.outlineRootsRev, i <- closure 0]
  where
    n = length pat
    segments = Map.fromList (zip [0 :: Int ..] pat)

    -- a @**@ may match nothing, so being at one is also being past it.
    closure :: Int -> [Int]
    closure i = case Map.lookup i segments of
        Just DoubleStar -> i : closure (i + 1)
        _ -> [i]

    -- where reading one path segment at suffix @i@ leaves the pattern.
    consume :: Text -> Int -> [Int]
    consume seg i = case Map.lookup i segments of
        Just (Lit l) | l == seg -> closure (i + 1)
        Just Star -> closure (i + 1)
        Just DoubleStar -> closure i
        _ -> []

    go :: Set (Ref, Int) -> Set Ref -> [(Ref, Int)] -> Set Ref
    go _ matched [] = matched
    go visited matched (item@(ref, i) : rest)
        | item `Set.member` visited = go visited matched rest
        | otherwise = case Map.lookup ref o.outlineEntries of
            Nothing -> go (Set.insert item visited) matched rest
            Just e ->
                let after = consume e.entryShorthand i
                    matched' = if n `elem` after then Set.insert ref matched else matched
                    next = [(c, j) | c <- e.entryChildrenRev, j <- after, j < n]
                 in go (Set.insert item visited) matched' (next <> rest)

-- | How many paths per node the callers in this tree ask 'outlinePaths' for.
pathLimit :: Int
pathLimit = 8

{- | The rendered length (as @\/a\/b@, one character per separator) above
which a listed path is not spelled out in full: 'outlinePaths' keeps only
its last segments and 'renderListedPath' shows them after a @**\/@.

Counted in characters, not segments, for two reasons. The listing is ordered
by rendered length first, so every path at or under the cap sorts before
every path over it: the paths that are still spelled out are exactly the
ones the uncapped listing had at or under the cap, in the same order and at
the same positions. And characters are what a path costs to hold and to
send, whatever its segments are called.

256 so that a recipe's paths (a few dozen characters; the deepest a @serve@
test asserts is 161) are left alone, while a node on a chain of thousands
costs a few hundred characters a path instead of its depth. Not derived from
a survey of real graphs: change it here.
-}
pathCap :: Int
pathCap = 256

{- | A path as 'outlinePaths' lists it.

'listedWidth' is always the full path's rendered length (as @\/a\/b@), even
when only its tail is kept, so a capped path still sorts by how long it
really is. 'listedSegments' is every segment, root first, when
'listedWidth' is at most the cap; otherwise ('listedCapped') it is the
longest tail whose own rendered length is at most the cap, and never less
than the node's own segment.
-}
data ListedPath = ListedPath
    { listedWidth :: !Int
    , listedCapped :: !Bool
    , listedSegments :: ![Text]
    }
    deriving (Show, Eq)

{- | What a listed path shows, the way 'Salmon.Actions.Serve.worldPaths'
renders paths (segments joined by @\/@, no leading one): the path itself,
or for a capped one @**\/@ and its tail. Either is a pattern that selects
the node ('matchOutline'); a capped one may select other nodes as well.
This is the one place that decides what a capped path looks like.
-}
renderListedPath :: ListedPath -> Text
renderListedPath p
    | p.listedCapped = "**/" <> Text.intercalate "/" p.listedSegments
    | otherwise = Text.intercalate "/" p.listedSegments

{- | @outlinePaths limit cap@: at most @limit@ paths to each node, shortest
first by rendered length. A path whose rendered length is at most @cap@ is
listed in full, and those are exactly, in the same order, what sorting
'pathedRefs' per 'Ref' by (rendered length, segments) gives at or under
@cap@. A longer path keeps only its tail (see 'ListedPath'): the capped
paths listed are still the shortest by rendered length, but which of several
of equal length are kept, and in what order, is decided step by step on what
each step still has (their tails read from the node upwards), not over every
path; two that leave the same tail show the same text and are listed once
(the shorter), so a node can list fewer than @limit@ although it has more
paths. A node with more paths
than that is reachable by paths not listed here, and a pattern built from
one of those still selects it ('matchOutline').

Computed from the top down, each node's paths from those of the nodes above
it; the shortest paths to a node extend shortest paths to the node above, so
keeping @limit@ at each step loses none of the final ones that are spelled
out. Each step handles at most @limit@ paths per edge of at most @cap@
characters (plus the node's own segment) each, so the cost is per node and
edge, not per node times its depth: on a chain the uncapped listing held
nodes x depth segments.
-}
outlinePaths :: Int -> Int -> Outline -> Map Ref [ListedPath]
outlinePaths limit cap o = List.foldl' place Map.empty topDown
  where
    childrenOf ref = maybe [] (.entryChildrenRev) (Map.lookup ref o.outlineEntries)

    parents :: Map Ref [Ref]
    parents = Map.fromListWith (<>) [(c, [p]) | (p, e) <- Map.toList o.outlineEntries, c <- e.entryChildrenRev]

    roots = Set.fromList o.outlineRootsRev

    -- every node after all the nodes above it (a depth-first post-order,
    -- reversed). An edge closing a cycle is one whose upper end comes later
    -- and has no paths yet, so it contributes none.
    topDown :: [Ref]
    topDown = snd (List.foldl' visit (Set.empty, []) o.outlineRootsRev)

    visit :: (Set Ref, [Ref]) -> Ref -> (Set Ref, [Ref])
    visit (seen, done) ref
        | ref `Set.member` seen = (seen, done)
        | otherwise =
            let (seen', done') = List.foldl' visit (Set.insert ref seen, done) (childrenOf ref)
             in (seen', ref : done')

    place :: Map Ref [ListedPath] -> Ref -> Map Ref [ListedPath]
    place acc ref = case Map.lookup ref o.outlineEntries of
        Nothing -> acc
        Just e ->
            let seg = e.entryShorthand
                own = [extend seg (ListedPath 0 False []) | ref `Set.member` roots]
                inherited =
                    [ extend seg p
                    | parent <- Map.findWithDefault [] ref parents
                    , p <- Map.findWithDefault [] parent acc
                    ]
             in Map.insert ref (shortest (own <> inherited)) acc

    extend :: Text -> ListedPath -> ListedPath
    extend seg p
        | not p.listedCapped && width <= cap = ListedPath width False (p.listedSegments <> [seg])
        | otherwise = ListedPath width True (tailWithin (p.listedSegments <> [seg]))
      where
        width = p.listedWidth + Text.length seg + 1

    -- the longest tail of these segments rendering in at most @cap@
    -- characters, and at least the last one.
    tailWithin :: [Text] -> [Text]
    tailWithin segs = case reverse segs of
        [] -> []
        (lst : before) -> go [lst] (Text.length lst + 1) before
      where
        go kept _ [] = kept
        go kept w (s : rest)
            | w' <= cap = go (s : kept) w' rest
            | otherwise = kept
          where
            w' = w + Text.length s + 1

    -- the @limit@ shortest of these paths, without repeats. Spelled-out
    -- paths compare as the uncapped listing did (length, then segments);
    -- capped ones, all longer, by their tail from the node upwards. Capped
    -- paths that leave the same tail show the same text whatever their
    -- length, so only the shortest of them is kept (and the longer ones'
    -- extensions would show one text again, so dropping them here drops
    -- nothing a node below would list).
    shortest :: [ListedPath] -> [ListedPath]
    shortest = take limit . distinctTails Set.empty . Map.elems . Map.fromList . map (\p -> (key p, p))

    distinctTails :: Set [Text] -> [ListedPath] -> [ListedPath]
    distinctTails _ [] = []
    distinctTails seen (p : ps)
        | not p.listedCapped = p : distinctTails seen ps
        | p.listedSegments `Set.member` seen = distinctTails seen ps
        | otherwise = p : distinctTails (Set.insert p.listedSegments seen) ps

    key :: ListedPath -> (Int, Bool, [Text])
    key p = (p.listedWidth, p.listedCapped, if p.listedCapped then reverse p.listedSegments else p.listedSegments)

-------------------------------------------------------------------------------

{- | Every node's path (as segments, root-to-node) paired with the 'Ref' found
there. One entry per /occurrence/, like 'pathedNodes'.
-}
pathedRefs :: Cofree Graph Op -> [([Text], Ref)]
pathedRefs = map (\(path, ref, _help) -> (path, ref)) . pathedNodes

{- | Like 'pathedRefs', but also carries each node's 'Salmon.Builtin.Extension.help' text.

One entry per occurrence in the expanded tree, so its length is exponential
in the number of shared dependencies stacked on one another: a listing for
whoever asked to see every path, never a step towards answering something
else. 'outline' is that step.
-}
pathedNodes :: Cofree Graph Op -> [([Text], Ref, Text)]
pathedNodes = go []
  where
    go :: [Text] -> Cofree Graph Op -> [([Text], Ref, Text)]
    go pfx (x :< g) =
        case x.node of
            Actionless -> concatMap (go pfx) (toList g)
            Actions act ->
                let path = pfx <> [shorthand act]
                 in (path, act.extension.ref, act.extension.help) : concatMap (go path) (toList g)

-------------------------------------------------------------------------------

-- | @resolveSelectors cograph selectPatterns excludePatterns@: @(selected, excluded)@.
resolveSelectors ::
    Cofree Graph Op ->
    [Text] ->
    [Text] ->
    (Set Ref, Set Ref)
resolveSelectors cograph selectPatterns excludePatterns =
    (selected, excluded)
  where
    o = outline cograph
    matches pats = Set.unions [matchOutline (parsePattern pat) o | pat <- pats]
    allRefs = outlineRefs o
    selectedBase = if null selectPatterns then allRefs else matches selectPatterns
    excluded = matches excludePatterns
    selected = selectedBase `Set.difference` excluded

{- | Like 'resolveSelectors', but rewrite-aware (see @specs\/advance-querying.md@
and (R4) in @specs\/per-node-state-machines-remaining.md@): a pattern is
resolved as a path glob against the /declared/ @cograph@ exactly as before,
__except__ one beginning with @#@, which instead matches by 'Ref' — either a
declared node's own, or (via 'Salmon.Op.Rewrite.membersOf') a
rewrite-introduced node's, expanded back to the declared nodes it stands in
for.

That fallback exists because a path glob fundamentally cannot address a
rewrite-introduced node (a package-install batch, say): such a node was
never declared, so it has no position in @cograph@ for a pattern to match —
it only exists in 'computed', produced after the fold. Its 'Ref' is the one
thing about it a pattern /can/ name, and it is exactly the text
'shortRef'\/'renderAnnotated' already print (the @#@ prefix mirrors
'renderAnnotated's own @" #" <> shortRef ref@ disambiguation suffix, so what
a render prints can be pasted straight back in as a selector). A fragment
matches as a prefix of either the short or the full 'Ref' text, so an
operator can paste the short form from a tree\/dag render or a longer,
disambiguating chunk of a full ref if a short one turns out ambiguous.

Every result is still a __declared__ 'Ref' set — this does not change what
'query plan'\/'query show' consume, since 'run up'/'run down''s
@phaseIgnored@ and 'Salmon.Op.Rewrite.collectDynamic' are both keyed on
declared refs. Addressing a batch by its own ref is therefore equivalent to
addressing every declared node it was built from — excluding \"the batch\"
/is/ excluding all 20 packages that went into it, which is the only
coherent meaning a plan (a set of declared exclusions consulted /before/ any
rewrite runs) can give it.
-}
resolveRewrittenSelectors ::
    Cofree Graph Op ->
    Rewritten Extension ->
    [Text] ->
    [Text] ->
    (Set Ref, Set Ref)
resolveRewrittenSelectors cograph computed selectPatterns excludePatterns =
    (selected, excluded)
  where
    o = outline cograph
    allRefs = outlineRefs o

    (selRefPats, selPathPats) = List.partition isRefPattern selectPatterns
    (excRefPats, excPathPats) = List.partition isRefPattern excludePatterns

    matchesOf :: [Text] -> [Text] -> Set Ref
    matchesOf pathPats refPats =
        Set.unions [matchOutline (parsePattern pat) o | pat <- pathPats]
            `Set.union` Set.unions (map matchRefPattern refPats)

    -- an empty select list still means "everything", exactly as
    -- 'resolveSelectors' — checked against the *combined* pattern list, not
    -- just its path half, or a select made of nothing but '#'-patterns
    -- would silently widen to "everything" instead of narrowing to what was
    -- actually asked for.
    selectedBase = if null selectPatterns then allRefs else matchesOf selPathPats selRefPats
    excluded = matchesOf excPathPats excRefPats
    selected = selectedBase `Set.difference` excluded

    isRefPattern :: Text -> Bool
    isRefPattern = Text.isPrefixOf "#"

    -- every declared 'Ref' a '#'-pattern resolves to: its direct matches
    -- among declared nodes, plus every declared member of a matching
    -- computed (rewrite-introduced) node.
    matchRefPattern :: Text -> Set Ref
    matchRefPattern pat = declaredHits `Set.union` viaComputed
      where
        fragment = Text.drop 1 pat
        declaredHits = Set.filter (matchesRefFragment fragment) allRefs
        computedHits = [cref | cref <- Map.keys (Dag.dagNodes computed.computedDag), matchesRefFragment fragment cref]
        viaComputed = Set.unions (map (Rewrite.membersOf computed) computedHits)

    matchesRefFragment :: Text -> Ref -> Bool
    matchesRefFragment fragment ref = fragment `Text.isPrefixOf` shortRef ref || fragment `Text.isPrefixOf` unRef ref

-------------------------------------------------------------------------------

{- | Rewrites every node whose 'Salmon.Op.Ref.Ref' is in the given set so its
'Salmon.Builtin.Extension.check' unconditionally reports 'Skipped',
leaving 'up'/'down'/'ref'/'dynamics' and the graph topology untouched.
This is the one producer of 'Skipped': it is a statement about a decision
made over the node, not about the node's effect.
Relies on 'OpGraph's derived 'Functor' recursing through the effectful
'predecessors' field (works because 'Op's @meval@ is 'Data.Functor.Identity',
itself a 'Functor') and on 'Actions'' own 'Functor' instance over its
extension type.
-}
forceSkip :: Set Ref -> Op -> Op
forceSkip refs = fmap (fmap rewrite)
  where
    rewrite :: Extension -> Extension
    rewrite ext
        | ext.ref `Set.member` refs = ext{check = pure Skipped}
        | otherwise = ext

-------------------------------------------------------------------------------

{- | Mirrors 'Salmon.Actions.Help.printHelpCograph', annotating matched paths.

@dedupe@: the same 'Ref' can occur at several paths (a shared predecessor); when
'True', only its first-encountered occurrence is printed instead of one line per
path. @showDescriptions@: when 'True', a node's 'Salmon.Builtin.Extension.help'
text (if non-empty) is printed on its own indented line right below the node's
path, prefixed with @"  # "@.

Path text alone doesn't always identify a node: sibling nodes built with the
same 'Salmon.Builtin.Extension.ShortHand' (e.g. several migration files each
going through the same @pg-script@ builder) render the exact same path text
while carrying distinct 'Ref's. Every occurrence of such a colliding path
(post-dedupe) is suffixed with @" #" <> 'shortRef' ref@ — stable across runs
and independent of traversal order, unlike an incrementing counter — so the
repeats are visibly distinguished instead of looking like accidental
duplicates.
-}
printAnnotated :: Cofree Graph Op -> Set Ref -> Set Ref -> Bool -> Bool -> IO ()
printAnnotated cograph selected excluded dedupe showDescriptions =
    traverse_ Text.putStrLn (renderAnnotated cograph selected excluded dedupe showDescriptions)

-- | Pure line-rendering behind 'printAnnotated' (kept separate so it's testable without IO capture).
renderAnnotated :: Cofree Graph Op -> Set Ref -> Set Ref -> Bool -> Bool -> [Text]
renderAnnotated cograph selected excluded dedupe showDescriptions =
    concatMap render entries
  where
    entries
        | dedupe = outlineFirstPaths (outline cograph)
        | otherwise = pathedNodes cograph

    -- how many (post-dedupe) entries render to this exact path text; >1 means it needs disambiguating.
    pathCounts :: Map Text Int
    pathCounts = Map.fromListWith (+) [(pathText path, 1 :: Int) | (path, _, _) <- entries]

    render :: ([Text], Ref, Text) -> [Text]
    render (path, ref, help) = line : descLine
      where
        key = pathText path
        collides = Map.findWithDefault 0 key pathCounts > 1
        line = key <> (if collides then " #" <> shortRef ref else "") <> annotation ref
        descLine = ["  # " <> help | showDescriptions && not (Text.null help)]

    annotation ref
        | ref `Set.member` excluded = " [excluded]"
        | ref `Set.member` selected = " [selected]"
        | otherwise = ""

-------------------------------------------------------------------------------

{- | An exclusion plan resolved against one specific directive.

'planDirective' is populated only when @query plan@ is run with
@--embed-directive@ — by default a plan is a small companion file that
travels alongside its directive (checked via 'planDirectiveDigest'), not a
copy of it. Embedding is for the case where you want the plan itself to be a
standalone, replayable artifact (e.g. archived for an audit trail); recover
the embedded bytes with @query extract-directive@ rather than reading this
field directly, so a caller never has to care whether a given plan carries
one.
-}
data Plan = Plan
    { planDirectiveDigest :: Text
    , planExcludedRefs :: [Ref]
    , planExcludedPatterns :: [Text]
    , planDirective :: Maybe Text
    }
    deriving (Show, Eq, Generic)

instance ToJSON Plan
instance FromJSON Plan

{- | sha256 of raw bytes, hex-encoded. Callers must hash the exact bytes read
off stdin, never a re-'Data.Aeson.encode'd value (aeson gives no
cross-invocation guarantee that decode-then-re-encode round-trips byte for
byte).
-}
digestBytes :: LByteString.ByteString -> Text
digestBytes = Text.concat . map hex . ByteString.unpack . SHA256.hashlazy
  where
    hex w =
        let s = showHex w ""
         in Text.pack (if length s == 1 then '0' : s else s)