Skip to main content

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}