mandible_core/provenance.rs
1//! Per-item provenance: which extraction source(s) contributed a
2//! [`crate::CommandNode`], [`crate::Flag`], or [`crate::Positional`], and the
3//! two-axis [`Authority`] each source carries for merge decisions.
4//!
5//! See spec §4.2 and §4.4. Provenance lives on each item individually —
6//! never as one badge for a whole tree — because after a multi-tier merge a
7//! node's own fields and its children's fields may legitimately come from
8//! different sources, and a single node-level badge covering both would lie
9//! about the children.
10
11use serde::{Deserialize, Serialize};
12use smallvec::SmallVec;
13
14/// Which extraction source(s) contributed to an item, in contribution order,
15/// plus a confidence score set only by heuristic tiers.
16#[derive(Debug, Clone, PartialEq, Default)]
17pub struct Provenance {
18 /// Contributing sources, ordered by contribution (earliest first).
19 pub sources: SmallVec<[Source; 2]>,
20 /// Set only when a heuristic tier (e.g. Tier B help-text) produced this
21 /// item; `None` for structured/authoritative sources.
22 pub confidence: Option<f32>,
23}
24
25/// Serde representation of [`Provenance`], used instead of deriving
26/// `Serialize`/`Deserialize` directly on the struct: `smallvec`'s own
27/// `serde` feature currently miscompiles against the split `serde_core`
28/// crate introduced in `serde` 1.0.229 (a lifetime error at the derive
29/// site). Serializing through a plain `Vec` sidesteps that entirely and
30/// keeps the on-the-wire shape identical (`{"sources": [...], "confidence":
31/// ...}`).
32#[derive(Serialize, Deserialize)]
33struct ProvenanceRepr {
34 sources: Vec<Source>,
35 confidence: Option<f32>,
36}
37
38impl Serialize for Provenance {
39 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
40 where
41 S: serde::Serializer,
42 {
43 ProvenanceRepr {
44 sources: self.sources.to_vec(),
45 confidence: self.confidence,
46 }
47 .serialize(serializer)
48 }
49}
50
51impl<'de> Deserialize<'de> for Provenance {
52 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
53 where
54 D: serde::Deserializer<'de>,
55 {
56 let repr = ProvenanceRepr::deserialize(deserializer)?;
57 Ok(Provenance {
58 sources: SmallVec::from_vec(repr.sources),
59 confidence: repr.confidence,
60 })
61 }
62}
63
64impl Provenance {
65 /// A `Provenance` crediting a single source, with no confidence score.
66 pub fn single(source: Source) -> Provenance {
67 let mut sources = SmallVec::new();
68 sources.push(source);
69 Provenance {
70 sources,
71 confidence: None,
72 }
73 }
74
75 /// A `Provenance` crediting a single heuristic source with a confidence
76 /// score.
77 pub fn with_confidence(source: Source, confidence: f32) -> Provenance {
78 let mut sources = SmallVec::new();
79 sources.push(source);
80 Provenance {
81 sources,
82 confidence: Some(confidence),
83 }
84 }
85
86 /// The highest authority on `axis` among this item's contributing
87 /// sources. `0` if there are no contributing sources.
88 pub fn effective_authority(&self, axis: Axis) -> u8 {
89 self.sources
90 .iter()
91 .map(|s| s.authority().on(axis))
92 .max()
93 .unwrap_or(0)
94 }
95
96 /// Whether an item carrying this provenance could, in principle, have
97 /// been described — spec §13's metric design rules. `true` when *any*
98 /// contributing source [`Source::can_describe`] (a flag merged from
99 /// several tiers is describable if even one of them could have
100 /// supplied prose, e.g. a synopsis spelling later reconciled against a
101 /// structured entry — see `help_text::sections::flag_spelling_already_present`).
102 /// `true` also when there are no contributing sources at all: an empty
103 /// `Provenance` is not this codebase's way of saying "usage-synopsis
104 /// only," so it must not silently disappear from a describability
105 /// count the way a real `HelpTextSynopsis`-only item correctly does.
106 pub fn describable(&self) -> bool {
107 self.sources.is_empty() || self.sources.iter().any(Source::can_describe)
108 }
109
110 /// Merge another `Provenance` into this one: union the source lists
111 /// (deduplicated, order preserved) and combine confidence
112 /// conservatively (the lower of the two, since overall trust is bounded
113 /// by the least-confident contributor).
114 pub fn absorb(&mut self, other: &Provenance) {
115 for s in &other.sources {
116 if !self.sources.contains(s) {
117 self.sources.push(s.clone());
118 }
119 }
120 self.confidence = match (self.confidence, other.confidence) {
121 (Some(a), Some(b)) => Some(a.min(b)),
122 (Some(a), None) => Some(a),
123 (None, Some(b)) => Some(b),
124 (None, None) => None,
125 };
126 }
127}
128
129/// One axis of [`Authority`]: structural facts (names, nesting, arity, which
130/// flags exist) vs. prose (descriptions, summaries, examples).
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum Axis {
133 /// Trust for names, nesting, arity, which flags exist.
134 Structural,
135 /// Trust for descriptions, summaries, examples.
136 Prose,
137}
138
139/// The two-axis trust level a [`Source`] carries. See spec §4.4's authority
140/// table — the tier with the best structure is frequently not the tier with
141/// the best prose, so merge resolves each axis independently rather than by
142/// a single priority order.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub struct Authority {
145 /// Trust for names, nesting, arity, which flags exist.
146 pub structural: u8,
147 /// Trust for descriptions, summaries, examples.
148 pub prose: u8,
149}
150
151impl Authority {
152 /// The authority value for a given axis.
153 pub fn on(&self, axis: Axis) -> u8 {
154 match axis {
155 Axis::Structural => self.structural,
156 Axis::Prose => self.prose,
157 }
158 }
159}
160
161/// The origin of a piece of extracted data. See spec §4.2 and §7.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub enum Source {
164 /// A native, version-accurate dynamic probe (Tier E): cobra
165 /// `__complete`, clap `CompleteEnv`, argcomplete.
166 NativeDynamic {
167 /// e.g. `"cobra-dunder-complete"`, `"clap-complete-env"`.
168 ///
169 /// `String` rather than `&'static str`: a `Source` must round-trip
170 /// through the on-disk cache (spec §11), and a borrowed `'static`
171 /// string cannot in general be produced by `Deserialize` without
172 /// leaking memory.
173 protocol: String,
174 },
175 /// A vendored or live structured catalog (Tier A).
176 KnownSpec {
177 /// e.g. `"carapace"`, `"withfig"`.
178 provider: String,
179 },
180 /// Structural parsing of a generated shell completion script (Tier C).
181 CompletionScript {
182 /// e.g. `"zsh"`, `"bash"`.
183 shell: String,
184 },
185 /// Man page extraction (Tier D).
186 ManPage {
187 /// Whether the page used semantic `mdoc(7)` macros or plain `man(7)`.
188 format: ManFormat,
189 },
190 /// `--help`/`-h`/`help` grammar parsing (Tier B) of a structured block
191 /// (an options table, `.TP`-shaped entry, or similar) that carries
192 /// prose alongside each flag.
193 HelpText,
194 /// `--help`/`-h`/`help` grammar parsing (Tier B) of a **usage synopsis**
195 /// line specifically (spec [M-15]): `git --help`'s
196 /// `[-p | --paginate | -P | --no-pager]`, mined by
197 /// `help_text::sections::extract_usage_flags`. A separate variant
198 /// rather than a field on `HelpText`, because a synopsis is genuinely a
199 /// different extraction site — the same reason `ManPage` carries
200 /// `format` and `CompletionScript` carries `shell` instead of `HelpText`
201 /// growing a field each of them would also need.
202 ///
203 /// A usage synopsis lists spellings and value shapes only, **never**
204 /// prose, by construction — spec §7 Tier B forbids fabricating a
205 /// description for one from neighbouring text. A flag whose only
206 /// source is this variant is therefore structurally undescribable, not
207 /// merely undescribed: [`Source::can_describe`] says so, and spec
208 /// §13's `pct_flags_with_text` excludes it from the denominator rather than
209 /// punishing recall for having found it (the defect [M-15] and this
210 /// redefinition both exist to fix — see spec §13's metric design
211 /// rules).
212 HelpTextSynopsis,
213 /// A user-local override file (Tier F).
214 UserOverride,
215}
216
217/// Which man page macro package produced a [`Source::ManPage`] item.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219pub enum ManFormat {
220 /// Semantic macros (`.Fl`, `.Ar`, `.Nm`) — real structure, not inference.
221 Mdoc,
222 /// Typeset prose with weak semantic tagging.
223 Man,
224}
225
226impl Source {
227 /// This source's two-axis [`Authority`], per spec §4.4's table.
228 pub fn authority(&self) -> Authority {
229 match self {
230 Source::UserOverride => Authority {
231 structural: 255,
232 prose: 255,
233 },
234 Source::NativeDynamic { .. } => Authority {
235 structural: 200,
236 prose: 40,
237 },
238 Source::CompletionScript { .. } => Authority {
239 structural: 150,
240 prose: 30,
241 },
242 Source::KnownSpec { .. } => Authority {
243 structural: 120,
244 prose: 200,
245 },
246 Source::ManPage { .. } => Authority {
247 structural: 60,
248 prose: 180,
249 },
250 // Same authority for both help-text variants: this split is
251 // about *measurement* (can this source's flag carry a
252 // description at all — see `can_describe`), not about merge
253 // precedence, so a synopsis-derived flag competes for a merge
254 // exactly as a table-derived one would.
255 Source::HelpText | Source::HelpTextSynopsis => Authority {
256 structural: 80,
257 prose: 120,
258 },
259 }
260 }
261
262 /// Whether this source could, in principle, have supplied a
263 /// description — spec §13's metric design rules (rule 2:
264 /// "denominators are conditioned on what the source could have
265 /// provided"). `false` only for [`Source::HelpTextSynopsis`]: a usage
266 /// synopsis lists spellings and value shapes, never prose, by
267 /// construction. Every other source at least *could* have carried a
268 /// description, whether or not it did for a given flag.
269 pub fn can_describe(&self) -> bool {
270 !matches!(self, Source::HelpTextSynopsis)
271 }
272
273 /// A short, human-readable label for UI footers, e.g. `"carapace"`,
274 /// `"help-text"`.
275 pub fn label(&self) -> String {
276 match self {
277 Source::NativeDynamic { protocol } => protocol.to_string(),
278 Source::KnownSpec { provider } => provider.to_string(),
279 Source::CompletionScript { shell } => format!("completion-{shell}"),
280 Source::ManPage {
281 format: ManFormat::Mdoc,
282 } => "man(mdoc)".to_string(),
283 Source::ManPage {
284 format: ManFormat::Man,
285 } => "man".to_string(),
286 Source::HelpText => "help-text".to_string(),
287 Source::HelpTextSynopsis => "help-text-synopsis".to_string(),
288 Source::UserOverride => "override".to_string(),
289 }
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn authority_table_matches_spec() {
299 assert_eq!(
300 Source::UserOverride.authority(),
301 Authority {
302 structural: 255,
303 prose: 255
304 }
305 );
306 assert_eq!(
307 Source::NativeDynamic {
308 protocol: "x".to_string()
309 }
310 .authority(),
311 Authority {
312 structural: 200,
313 prose: 40
314 }
315 );
316 assert_eq!(
317 Source::CompletionScript {
318 shell: "zsh".to_string()
319 }
320 .authority(),
321 Authority {
322 structural: 150,
323 prose: 30
324 }
325 );
326 assert_eq!(
327 Source::KnownSpec {
328 provider: "carapace".to_string()
329 }
330 .authority(),
331 Authority {
332 structural: 120,
333 prose: 200
334 }
335 );
336 assert_eq!(
337 Source::ManPage {
338 format: ManFormat::Mdoc
339 }
340 .authority(),
341 Authority {
342 structural: 60,
343 prose: 180
344 }
345 );
346 assert_eq!(
347 Source::HelpText.authority(),
348 Authority {
349 structural: 80,
350 prose: 120
351 }
352 );
353 // [M-15]/§13 metric redefinition: same authority as `HelpText` —
354 // the split is about measurement, not merge precedence.
355 assert_eq!(
356 Source::HelpTextSynopsis.authority(),
357 Source::HelpText.authority()
358 );
359 }
360
361 #[test]
362 fn only_help_text_synopsis_cannot_describe() {
363 assert!(Source::HelpText.can_describe());
364 assert!(Source::UserOverride.can_describe());
365 assert!(Source::KnownSpec {
366 provider: "carapace".to_string()
367 }
368 .can_describe());
369 assert!(!Source::HelpTextSynopsis.can_describe());
370 }
371
372 #[test]
373 fn provenance_describable_is_true_if_any_source_can_describe() {
374 let synopsis_only = Provenance::single(Source::HelpTextSynopsis);
375 assert!(!synopsis_only.describable());
376
377 let mut mixed = Provenance::single(Source::HelpTextSynopsis);
378 mixed.absorb(&Provenance::single(Source::HelpText));
379 assert!(mixed.describable());
380
381 assert!(Provenance::default().describable());
382 }
383
384 #[test]
385 fn effective_authority_is_max_over_sources() {
386 let p = Provenance {
387 sources: SmallVec::from_vec(vec![
388 Source::HelpText,
389 Source::KnownSpec {
390 provider: "carapace".to_string(),
391 },
392 ]),
393 confidence: None,
394 };
395 assert_eq!(p.effective_authority(Axis::Prose), 200);
396 assert_eq!(p.effective_authority(Axis::Structural), 120);
397 }
398
399 #[test]
400 fn absorb_dedups_sources() {
401 let mut a = Provenance::single(Source::KnownSpec {
402 provider: "carapace".to_string(),
403 });
404 let b = Provenance::single(Source::KnownSpec {
405 provider: "carapace".to_string(),
406 });
407 a.absorb(&b);
408 assert_eq!(a.sources.len(), 1);
409 }
410
411 #[test]
412 fn absorb_takes_min_confidence() {
413 let mut a = Provenance::with_confidence(Source::HelpText, 0.9);
414 let b = Provenance::with_confidence(Source::HelpText, 0.4);
415 a.absorb(&b);
416 assert_eq!(a.confidence, Some(0.4));
417 }
418}