Skip to main content

nmbrs_runtime/
wrapper_resolver.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! SRD-32a — Wrapper composition resolver.
5//!
6//! Turns a parsed op template + the wrapper registry + the
7//! session-level default order into a concrete
8//! [`WrapperPlan`]: which wrappers to apply, in what order,
9//! with provenance tagging for diagnostics.
10//!
11//! Algorithm (4 passes, see SRD-32a §"Algorithm"):
12//! 1. Trigger fan-out — every wrapper whose `triggers(template)`
13//!    returns true is added with `OwnedField` provenance.
14//! 2. Transitive closure — close `requires_inner` edges,
15//!    tagging additions with `TransitiveFrom`.
16//! 3. Constraint validation — `mutually_exclusive_with`
17//!    pairs and `requires_inner` cycles surface as errors.
18//! 4. Topological order — innermost first, with the
19//!    session-level default order breaking ties; then a
20//!    `forbids_outer` post-scan rejects any ordering that
21//!    placed a forbidden wrapper outside its inner.
22
23use std::collections::{HashMap, HashSet};
24
25use crate::wrapper_registry::{WrapperName, WrapperRegistration, WrapperRegistry, WrapperSubject};
26
27/// Resolved wrapper composition for one op template.
28pub struct WrapperPlan {
29    /// Wrappers in composition order — innermost first
30    /// (built first; called last per cycle), outermost
31    /// last. The executor applies them in this order to
32    /// construct the final dispenser chain.
33    pub stack: Vec<&'static WrapperRegistration>,
34
35    /// Diagnostic record of which wrappers triggered
36    /// directly vs. via transitive activation. Used by
37    /// `nmbrs describe op` to explain why each wrapper
38    /// is present.
39    pub provenance: Vec<WrapperActivation>,
40}
41
42impl WrapperPlan {
43    /// Iterate the stack innermost-to-outermost.
44    pub fn iter_innermost_first(&self) -> impl Iterator<Item = &'static WrapperRegistration> + '_ {
45        self.stack.iter().copied()
46    }
47
48    /// Find the activation record for a wrapper name. Returns
49    /// `None` if the wrapper isn't in the plan.
50    pub fn activation(&self, name: WrapperName) -> Option<&WrapperActivation> {
51        self.provenance.iter().find(|a| a.wrapper() == name)
52    }
53}
54
55/// How a wrapper came to be in a plan — directly triggered
56/// by a field, transitively pulled in by another wrapper, or
57/// always-on.
58#[derive(Debug, Clone)]
59pub enum WrapperActivation {
60    /// Triggered directly by an owned field on the op
61    /// template (e.g. `validate` because `verify:` was
62    /// declared).
63    OwnedField {
64        wrapper: WrapperName,
65        field: &'static str,
66    },
67    /// Pulled in transitively by another wrapper's
68    /// `requires_inner`.
69    TransitiveFrom {
70        wrapper: WrapperName,
71        requested_by: WrapperName,
72    },
73    /// Always-on wrapper (e.g. `traverse`, `result`).
74    AlwaysOn { wrapper: WrapperName },
75}
76
77impl WrapperActivation {
78    pub fn wrapper(&self) -> WrapperName {
79        match self {
80            Self::OwnedField { wrapper, .. } => *wrapper,
81            Self::TransitiveFrom { wrapper, .. } => *wrapper,
82            Self::AlwaysOn { wrapper } => *wrapper,
83        }
84    }
85}
86
87/// Errors the resolver may surface. Each variant carries
88/// enough context for the caller to render an actionable
89/// diagnostic without re-walking the registry.
90#[derive(Debug)]
91pub enum ResolveError {
92    /// `forbids_outer` violation — wrapper `inner` declared
93    /// `outer` must not wrap it, but the resolved order
94    /// placed `outer` outside `inner`.
95    ForbiddenOuter {
96        inner: WrapperName,
97        outer: WrapperName,
98    },
99    /// `mutually_exclusive_with` violation — both triggered
100    /// for the same op.
101    MutuallyExclusive {
102        a: WrapperName,
103        b: WrapperName,
104        a_reason: WrapperActivation,
105        b_reason: WrapperActivation,
106    },
107    /// Constraint graph contains a `requires_inner` cycle
108    /// (e.g. A.requires_inner = [B] and B.requires_inner =
109    /// [A]). Almost always a registry-author bug; surface
110    /// at session start.
111    ConstraintCycle { cycle: Vec<WrapperName> },
112    /// Override referenced an unknown wrapper name. Carries
113    /// the closest registered name as a typo suggestion when
114    /// available.
115    UnknownWrapper {
116        name: String,
117        suggestion: Option<&'static str>,
118    },
119    /// `requires_inner` pointed at a wrapper that doesn't
120    /// exist in the registry. Almost always a registry-author
121    /// bug.
122    DanglingRequiresInner {
123        from: WrapperName,
124        missing: WrapperName,
125    },
126    /// SRD-32a Push 3 — an explicit `wrappers: { order: [...] }`
127    /// override is not a permutation of the wrappers triggered
128    /// on the op. Either a triggered wrapper is missing from
129    /// the override (`missing`), or the override names a
130    /// wrapper whose trigger doesn't fire (`extra`). Exactly
131    /// one of `missing`/`extra` is set per error; the resolver
132    /// reports the first violation it finds.
133    OverridePermutationMismatch {
134        missing: Option<WrapperName>,
135        extra: Option<WrapperName>,
136    },
137}
138
139impl std::fmt::Display for ResolveError {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        match self {
142            Self::ForbiddenOuter { inner, outer } => write!(
143                f,
144                "wrapper `{outer}` was placed outside `{inner}`, \
145                 which is forbidden by `{inner}`'s constraint graph",
146            ),
147            Self::MutuallyExclusive { a, b, .. } => write!(
148                f,
149                "wrappers `{a}` and `{b}` are mutually exclusive but \
150                 both triggered on this op",
151            ),
152            Self::ConstraintCycle { cycle } => {
153                write!(f, "wrapper requires_inner cycle: ")?;
154                for (i, n) in cycle.iter().enumerate() {
155                    if i > 0 {
156                        f.write_str(" → ")?;
157                    }
158                    write!(f, "{n}")?;
159                }
160                Ok(())
161            }
162            Self::UnknownWrapper { name, suggestion } => {
163                write!(f, "unknown wrapper `{name}`")?;
164                if let Some(s) = suggestion {
165                    write!(f, "; did you mean `{s}`?")?;
166                }
167                Ok(())
168            }
169            Self::DanglingRequiresInner { from, missing } => write!(
170                f,
171                "wrapper `{from}` declares requires_inner=[{missing}] but `{missing}` is not registered",
172            ),
173            Self::OverridePermutationMismatch { missing, extra } => {
174                if let Some(m) = missing {
175                    write!(
176                        f,
177                        "wrapper override is missing triggered wrapper `{m}` — \
178                         every wrapper that fires on this op must appear in \
179                         `wrappers: {{ order: [...] }}`"
180                    )
181                } else if let Some(e) = extra {
182                    write!(
183                        f,
184                        "wrapper override names `{e}`, but its trigger \
185                         condition is not satisfied for this op — remove it \
186                         from `wrappers: {{ order: [...] }}` or add the \
187                         trigger field that activates it"
188                    )
189                } else {
190                    write!(f, "wrapper override permutation mismatch")
191                }
192            }
193        }
194    }
195}
196
197impl std::error::Error for ResolveError {}
198
199/// Default composition order (innermost → outermost). Used
200/// by the resolver as a tiebreaker when constraints leave
201/// multiple valid orderings.
202///
203/// Matches the cascade hand-rolled in `activity.rs` today;
204/// tests pin this exact sequence to keep the migration
205/// byte-identical.
206pub const DEFAULT_ORDER: &[&str] = &[
207    // `tries` is the ABSOLUTE INNERMOST layer (SRD-82 Part 3b): it wraps the
208    // raw adapter dispenser directly, owning the attempt loop + per-attempt
209    // panic catch. Hand-placed in the cascade (before the plan loop); this
210    // slot keeps the PLAN's order aligned with the runtime truth when an
211    // op's `tries:` field puts it in the plan.
212    "tries", "traverse", "delay", "validate", "poll", "if", "result", "metrics",
213    // `memo` must appear before `dryrun` here so the topo-
214    // sort tiebreak places it INSIDE dryrun. Wrappers absent
215    // from this list get `order_index = usize::MAX` and
216    // therefore lose every tiebreak — which puts them at the
217    // outermost position. That's harmless when no wrapper
218    // forbids them being outside, but dryrun's
219    // `forbids_outer` set lists memo (dryrun must be the
220    // absolute outermost short-circuit), so memo missing
221    // from default order would surface as a
222    // `ForbiddenOuter` resolve error.
223    "memo",
224    // `gutter` sits beside memo for the same reason memo needs a
225    // slot: dryrun forbids it outer, so it must beat dryrun's
226    // tiebreak. Inside `while:` / `rate:` so each loop
227    // iteration publishes its own cell value.
228    "gutter",
229    // `rate:` paces inner iterations. Slotted inside
230    // `while:` so the acquire fires once per loop iteration;
231    // outside metrics/memo so the wait isn't counted against
232    // per-op service-time measurement.
233    "rate",
234    // `while:` sits outer of memo + metrics so each loop
235    // iteration's per-cycle effects (metric update, memo
236    // emit) fire once per iteration rather than once per
237    // outer dispatch.
238    "while",
239    // `dryrun` last so its short-circuit happens before any
240    // inner wrapper observes the dryrun stand-in's empty
241    // body. The DRYRUN registration's `forbids_outer = [every
242    // other wrapper]` pins this position structurally; the
243    // explicit slot here is the resolver's tiebreaker for any
244    // future wrapper that DRYRUN doesn't yet forbid.
245    "dryrun",
246    // `fields` is intentionally outer of everything
247    // including dryrun — so under `dryrun=fields` the fields
248    // wrapper's pre-execute render runs BEFORE DRYRUN's
249    // short-circuit (the fields render+println is the surface
250    // that produces the operator-visible "what would have
251    // been sent" output). Innermost-first list ordering
252    // means later index = outer position at execute time.
253    "fields",
254    // `errors` is the ABSOLUTE OUTERMOST layer (SRD-82 Part 3b):
255    // it observes the stack's one terminal outcome, routes it
256    // through the op's resolved ErrorPolicy, and applies the
257    // stop/fail effects. Hand-placed in the cascade (after the
258    // plan loop, mirroring the hand-placed innermost retry); this
259    // slot keeps the PLAN's order — telemetry, describe, override
260    // validation — aligned with the runtime truth. Outside dryrun
261    // / fields it is inert on their Ok short-circuits.
262    "errors",
263];
264
265/// Resolves a [`WrapperPlan`] for a parsed op template.
266///
267/// Stateless apart from the session config it holds (the
268/// default-order tiebreaker). Repeated calls with the same
269/// inputs produce identical plans.
270pub struct WrapperResolver {
271    /// Innermost-to-outermost tiebreaker order, validated
272    /// against the registry at construction time.
273    default_order: Vec<WrapperName>,
274}
275
276impl WrapperResolver {
277    /// Construct a resolver with the built-in default order.
278    pub fn with_default_order(registry: &WrapperRegistry) -> Result<Self, ResolveError> {
279        let names: Vec<&str> = DEFAULT_ORDER.to_vec();
280        Self::from_names(&names, registry)
281    }
282
283    /// Construct a resolver from an explicit list of wrapper
284    /// names (innermost-to-outermost). Validates against the
285    /// registry's constraint graph; rejects unknown names and
286    /// orderings that violate `forbids_outer`.
287    pub fn from_names(names: &[&str], registry: &WrapperRegistry) -> Result<Self, ResolveError> {
288        let mut order = Vec::with_capacity(names.len());
289        for name in names {
290            match registry.get_str(name) {
291                Some(reg) => order.push(reg.name),
292                None => {
293                    return Err(ResolveError::UnknownWrapper {
294                        name: (*name).to_string(),
295                        suggestion: registry.closest_match(name),
296                    });
297                }
298            }
299        }
300        // Check the default order against the WHOLE registry
301        // graph: every requires_inner must be satisfied by
302        // position, and no forbids_outer must be violated.
303        validate_order_against_registry(&order, registry)?;
304        Ok(Self {
305            default_order: order,
306        })
307    }
308
309    /// Resolve the wrapper plan for one op template.
310    pub fn resolve(
311        &self,
312        subject: WrapperSubject,
313        registry: &WrapperRegistry,
314    ) -> Result<WrapperPlan, ResolveError> {
315        // Pass 1 — trigger fan-out. Only wrappers legal at this subject's
316        // level are eligible (SRD-82/92 cross-level: an op resolve sees op
317        // wrappers, a phase resolve sees phase wrappers).
318        let mut activations: HashMap<WrapperName, WrapperActivation> = HashMap::new();
319        for reg in registry.iter() {
320            if reg.applies_at(subject.level()) && (reg.triggers)(subject) {
321                let activation = first_owned_field(reg, subject)
322                    .map(|f| WrapperActivation::OwnedField {
323                        wrapper: reg.name,
324                        field: f,
325                    })
326                    .unwrap_or(WrapperActivation::AlwaysOn { wrapper: reg.name });
327                activations.insert(reg.name, activation);
328            }
329        }
330
331        // Pass 2 — transitive closure on requires_inner.
332        let mut frontier: Vec<WrapperName> = activations.keys().copied().collect();
333        while let Some(w) = frontier.pop() {
334            let reg = registry
335                .get(w)
336                .expect("triggered wrapper must be registered");
337            for &needed in reg.requires_inner {
338                let needed_reg =
339                    registry
340                        .get(needed)
341                        .ok_or(ResolveError::DanglingRequiresInner {
342                            from: w,
343                            missing: needed,
344                        })?;
345                let _ = needed_reg;
346                if let std::collections::hash_map::Entry::Vacant(e) = activations.entry(needed) {
347                    e.insert(WrapperActivation::TransitiveFrom {
348                        wrapper: needed,
349                        requested_by: w,
350                    });
351                    frontier.push(needed);
352                }
353            }
354        }
355
356        // Pass 3a — mutually_exclusive_with.
357        for (&w, w_act) in &activations {
358            let reg = registry.get(w).unwrap();
359            for &peer in reg.mutually_exclusive_with {
360                if let Some(peer_act) = activations.get(&peer) {
361                    return Err(ResolveError::MutuallyExclusive {
362                        a: w,
363                        b: peer,
364                        a_reason: w_act.clone(),
365                        b_reason: peer_act.clone(),
366                    });
367                }
368            }
369        }
370
371        // Pass 3b — cycle check on requires_inner over the
372        // triggered set. DFS with grey/black coloring.
373        if let Some(cycle) = detect_cycle(&activations, registry) {
374            return Err(ResolveError::ConstraintCycle { cycle });
375        }
376
377        // Pass 4 — topological order with default-order tiebreaker.
378        let stack = topo_sort(&activations, registry, &self.default_order);
379
380        // Pass 4b — forbids_outer post-scan.
381        for (i, inner) in stack.iter().enumerate() {
382            for outer in &stack[i + 1..] {
383                if inner.forbids_outer.contains(&outer.name) {
384                    return Err(ResolveError::ForbiddenOuter {
385                        inner: inner.name,
386                        outer: outer.name,
387                    });
388                }
389            }
390        }
391
392        // Build provenance vec in stack order so iteration
393        // is intuitive ("first-built → last-built").
394        let provenance: Vec<WrapperActivation> = stack
395            .iter()
396            .map(|reg| activations.get(&reg.name).cloned().unwrap())
397            .collect();
398
399        Ok(WrapperPlan { stack, provenance })
400    }
401
402    /// The innermost-to-outermost default order this resolver
403    /// uses as a tiebreaker. Useful for diagnostics
404    /// (`nmbrs describe wrappers`).
405    pub fn default_order(&self) -> &[WrapperName] {
406        &self.default_order
407    }
408
409    /// SRD-32a Push 3 — resolve a plan using an explicit
410    /// per-op innermost-to-outermost order list. The list
411    /// MUST be a permutation of the wrappers triggered on
412    /// this op (after transitive activation):
413    ///
414    /// - listing a wrapper whose trigger doesn't fire is a
415    ///   hard error (silently dropping it would mask typos),
416    /// - omitting one whose trigger does fire is a hard
417    ///   error (skipping a wrapper changes semantics).
418    ///
419    /// All other constraint checks (`mutually_exclusive_with`,
420    /// `requires_inner` cycles, `forbids_outer`) run exactly
421    /// as they do for the default-order path — same code,
422    /// same error shapes.
423    pub fn resolve_with_order(
424        &self,
425        subject: WrapperSubject,
426        registry: &WrapperRegistry,
427        order: &[&str],
428    ) -> Result<WrapperPlan, ResolveError> {
429        // Pass 1+2 — compute the triggered set the same way
430        // `resolve` does. The override list is checked AGAINST
431        // this set, not given the freedom to override what
432        // triggers.
433        let mut activations: HashMap<WrapperName, WrapperActivation> = HashMap::new();
434        for reg in registry.iter() {
435            if reg.applies_at(subject.level()) && (reg.triggers)(subject) {
436                let activation = first_owned_field(reg, subject)
437                    .map(|f| WrapperActivation::OwnedField {
438                        wrapper: reg.name,
439                        field: f,
440                    })
441                    .unwrap_or(WrapperActivation::AlwaysOn { wrapper: reg.name });
442                activations.insert(reg.name, activation);
443            }
444        }
445        let mut frontier: Vec<WrapperName> = activations.keys().copied().collect();
446        while let Some(w) = frontier.pop() {
447            let reg = registry
448                .get(w)
449                .expect("triggered wrapper must be registered");
450            for &needed in reg.requires_inner {
451                let _ = registry
452                    .get(needed)
453                    .ok_or(ResolveError::DanglingRequiresInner {
454                        from: w,
455                        missing: needed,
456                    })?;
457                if let std::collections::hash_map::Entry::Vacant(e) = activations.entry(needed) {
458                    e.insert(WrapperActivation::TransitiveFrom {
459                        wrapper: needed,
460                        requested_by: w,
461                    });
462                    frontier.push(needed);
463                }
464            }
465        }
466
467        // Translate the override into WrapperName entries so
468        // we can compare to `activations`. Unknown names get
469        // a typo suggestion.
470        let mut override_names: Vec<WrapperName> = Vec::with_capacity(order.len());
471        for raw in order {
472            match registry.get_str(raw) {
473                Some(reg) => override_names.push(reg.name),
474                None => {
475                    return Err(ResolveError::UnknownWrapper {
476                        name: (*raw).to_string(),
477                        suggestion: registry.closest_match(raw),
478                    });
479                }
480            }
481        }
482
483        // Permutation rules: every triggered wrapper must
484        // appear; no wrapper appears that isn't triggered.
485        let triggered: std::collections::HashSet<WrapperName> =
486            activations.keys().copied().collect();
487        let in_override: std::collections::HashSet<WrapperName> =
488            override_names.iter().copied().collect();
489        for w in &triggered {
490            if !in_override.contains(w) {
491                return Err(ResolveError::OverridePermutationMismatch {
492                    missing: Some(*w),
493                    extra: None,
494                });
495            }
496        }
497        for w in &in_override {
498            if !triggered.contains(w) {
499                return Err(ResolveError::OverridePermutationMismatch {
500                    missing: None,
501                    extra: Some(*w),
502                });
503            }
504        }
505
506        // Mutual-exclusion + requires_inner cycle checks
507        // (same as `resolve`).
508        for (&w, w_act) in &activations {
509            let reg = registry.get(w).unwrap();
510            for &peer in reg.mutually_exclusive_with {
511                if let Some(peer_act) = activations.get(&peer) {
512                    return Err(ResolveError::MutuallyExclusive {
513                        a: w,
514                        b: peer,
515                        a_reason: w_act.clone(),
516                        b_reason: peer_act.clone(),
517                    });
518                }
519            }
520        }
521        if let Some(cycle) = detect_cycle(&activations, registry) {
522            return Err(ResolveError::ConstraintCycle { cycle });
523        }
524
525        // The override IS the order. Validate the
526        // requires_inner / forbids_outer constraints against
527        // it directly: each requires_inner pair must have
528        // the inner appear earlier; each forbids_outer pair
529        // must have the listed wrapper appear earlier or
530        // not at all.
531        let pos: HashMap<WrapperName, usize> = override_names
532            .iter()
533            .enumerate()
534            .map(|(i, &n)| (n, i))
535            .collect();
536        for &name in &override_names {
537            let reg = registry.get(name).unwrap();
538            for &needed in reg.requires_inner {
539                if let (Some(&me), Some(&inner)) = (pos.get(&name), pos.get(&needed))
540                    && inner >= me
541                {
542                    return Err(ResolveError::ForbiddenOuter {
543                        inner: needed,
544                        outer: name,
545                    });
546                }
547            }
548            for &forbidden in reg.forbids_outer {
549                if let (Some(&me), Some(&forb)) = (pos.get(&name), pos.get(&forbidden))
550                    && forb > me
551                {
552                    return Err(ResolveError::ForbiddenOuter {
553                        inner: name,
554                        outer: forbidden,
555                    });
556                }
557            }
558        }
559
560        let stack: Vec<&'static WrapperRegistration> = override_names
561            .iter()
562            .map(|n| registry.get(*n).unwrap())
563            .collect();
564        let provenance: Vec<WrapperActivation> = stack
565            .iter()
566            .map(|reg| activations.get(&reg.name).cloned().unwrap())
567            .collect();
568        Ok(WrapperPlan { stack, provenance })
569    }
570}
571
572/// First owned field present on the template, used to label
573/// `OwnedField` activations. Returns `None` when the wrapper
574/// has no owned fields (e.g. `traverse`, `result`); the caller
575/// falls back to `AlwaysOn`.
576fn first_owned_field(
577    reg: &'static WrapperRegistration,
578    subject: WrapperSubject,
579) -> Option<&'static str> {
580    for field in reg.owned_fields {
581        if subject.has_owned_field(field) {
582            return Some(*field);
583        }
584    }
585    None
586}
587
588fn detect_cycle(
589    activations: &HashMap<WrapperName, WrapperActivation>,
590    registry: &WrapperRegistry,
591) -> Option<Vec<WrapperName>> {
592    #[derive(Copy, Clone, PartialEq)]
593    enum Color {
594        White,
595        Grey,
596        Black,
597    }
598
599    let mut color: HashMap<WrapperName, Color> =
600        activations.keys().map(|&n| (n, Color::White)).collect();
601    let mut stack: Vec<WrapperName> = Vec::new();
602
603    fn dfs(
604        node: WrapperName,
605        color: &mut HashMap<WrapperName, Color>,
606        stack: &mut Vec<WrapperName>,
607        registry: &WrapperRegistry,
608        activations: &HashMap<WrapperName, WrapperActivation>,
609    ) -> Option<Vec<WrapperName>> {
610        color.insert(node, Color::Grey);
611        stack.push(node);
612        let reg = registry.get(node).unwrap();
613        for &needed in reg.requires_inner {
614            if !activations.contains_key(&needed) {
615                continue;
616            }
617            match color.get(&needed).copied().unwrap_or(Color::White) {
618                Color::Grey => {
619                    // cycle — slice from `needed` to top of stack
620                    let cycle_start = stack.iter().position(|&n| n == needed).unwrap();
621                    let mut cycle = stack[cycle_start..].to_vec();
622                    cycle.push(needed);
623                    return Some(cycle);
624                }
625                Color::White => {
626                    if let Some(c) = dfs(needed, color, stack, registry, activations) {
627                        return Some(c);
628                    }
629                }
630                Color::Black => {}
631            }
632        }
633        stack.pop();
634        color.insert(node, Color::Black);
635        None
636    }
637
638    let nodes: Vec<WrapperName> = activations.keys().copied().collect();
639    for n in nodes {
640        if color.get(&n).copied() == Some(Color::White)
641            && let Some(c) = dfs(n, &mut color, &mut stack, registry, activations)
642        {
643            return Some(c);
644        }
645    }
646    None
647}
648
649/// Topological sort of triggered wrappers honouring
650/// `requires_inner` (inner before outer). When the partial
651/// order leaves choices, the session-level default order
652/// breaks ties.
653///
654/// Uses Kahn's algorithm with a stable selection rule:
655/// among nodes with no remaining inner-edges, pick the one
656/// that appears earliest in `default_order`. Wrappers not
657/// listed in default_order sort last alphabetically.
658fn topo_sort(
659    activations: &HashMap<WrapperName, WrapperActivation>,
660    registry: &WrapperRegistry,
661    default_order: &[WrapperName],
662) -> Vec<&'static WrapperRegistration> {
663    // Build "inner_count" — number of triggered wrappers in
664    // this wrapper's requires_inner that haven't been emitted
665    // yet. When zero, the wrapper is eligible.
666    let mut inner_count: HashMap<WrapperName, usize> = HashMap::new();
667    for &w in activations.keys() {
668        let reg = registry.get(w).unwrap();
669        let count = reg
670            .requires_inner
671            .iter()
672            .filter(|n| activations.contains_key(*n))
673            .count();
674        inner_count.insert(w, count);
675    }
676
677    // Reverse adjacency: for each wrapper W, which triggered
678    // wrappers list W in their requires_inner?
679    let mut requires_me: HashMap<WrapperName, Vec<WrapperName>> = HashMap::new();
680    for &w in activations.keys() {
681        let reg = registry.get(w).unwrap();
682        for &needed in reg.requires_inner {
683            if activations.contains_key(&needed) {
684                requires_me.entry(needed).or_default().push(w);
685            }
686        }
687    }
688
689    let order_index: HashMap<WrapperName, usize> = default_order
690        .iter()
691        .enumerate()
692        .map(|(i, &n)| (n, i))
693        .collect();
694    let tiebreak = |a: &WrapperName, b: &WrapperName| {
695        let ai = order_index.get(a).copied().unwrap_or(usize::MAX);
696        let bi = order_index.get(b).copied().unwrap_or(usize::MAX);
697        ai.cmp(&bi).then_with(|| a.0.cmp(b.0))
698    };
699
700    let mut emitted = HashSet::new();
701    let mut out: Vec<&'static WrapperRegistration> = Vec::with_capacity(activations.len());
702    while emitted.len() < activations.len() {
703        let mut eligible: Vec<WrapperName> = activations
704            .keys()
705            .copied()
706            .filter(|w| !emitted.contains(w) && inner_count[w] == 0)
707            .collect();
708        eligible.sort_by(|a, b| tiebreak(a, b));
709        let next = eligible
710            .first()
711            .copied()
712            .expect("cycle detection should have caught a graph with no eligible node");
713        emitted.insert(next);
714        out.push(registry.get(next).unwrap());
715        if let Some(consumers) = requires_me.get(&next) {
716            for &c in consumers {
717                if let Some(slot) = inner_count.get_mut(&c) {
718                    *slot -= 1;
719                }
720            }
721        }
722    }
723    out
724}
725
726/// Validate an explicit innermost-to-outermost order
727/// against the registry's constraint graph. Used by
728/// [`WrapperResolver::from_names`] at startup so a
729/// misconfigured `--wrap-default-order` fails fast.
730fn validate_order_against_registry(
731    order: &[WrapperName],
732    registry: &WrapperRegistry,
733) -> Result<(), ResolveError> {
734    let pos: HashMap<WrapperName, usize> = order.iter().enumerate().map(|(i, &n)| (n, i)).collect();
735
736    for &name in order {
737        let reg = registry
738            .get(name)
739            .expect("name was looked up by from_names");
740
741        // requires_inner: every named inner must appear
742        // earlier (lower index) in the order — but only
743        // when both the requirer and the required are in
744        // the configured order. (A default-order list may
745        // omit wrappers that always get pulled in
746        // transitively.)
747        for &needed in reg.requires_inner {
748            if let (Some(&me), Some(&inner)) = (pos.get(&name), pos.get(&needed))
749                && inner >= me
750            {
751                return Err(ResolveError::ForbiddenOuter {
752                    inner: needed,
753                    outer: name,
754                });
755            }
756        }
757
758        // forbids_outer: every named outer must appear
759        // earlier (lower index) — i.e. not outside this
760        // wrapper.
761        for &forbidden in reg.forbids_outer {
762            if let (Some(&me), Some(&forb)) = (pos.get(&name), pos.get(&forbidden))
763                && forb > me
764            {
765                return Err(ResolveError::ForbiddenOuter {
766                    inner: name,
767                    outer: forbidden,
768                });
769            }
770        }
771    }
772    Ok(())
773}
774
775// Tests in this module are unit-level against synthetic
776// registries — they don't rely on the production
777// wrapper registrations. Integration tests covering the
778// production registry sit in `wrappers.rs` alongside the
779// existing wrapper tests.