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(®.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(®.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.