Skip to main content

veks_completion/
options.rs

1// Copyright (c) Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared, per-command option definitions with a project-wide consistency
5//! guarantee.
6//!
7//! This replaces the old global value-provider map. The rules it enforces:
8//!
9//! * **Per-command attachment.** Nothing is injected globally. An option only
10//!   exists on a command that attaches it, and its value completer is attached
11//!   to that command's node — never to commands that don't declare it.
12//! * **DRY definition, one parse-shape per name.** The *parse* definition of an
13//!   option ([`OptionDef`] — flag spelling, arity, value-takes-ness, value
14//!   name) is shared. Many commands may attach the same named option, but for a
15//!   given name it must be *physically the same definition*. Attaching a
16//!   conflicting definition for an already-defined name is a **runtime-verifiable
17//!   error** ([`OptionConflict`]). This makes "two commands spell `--at`
18//!   differently" impossible.
19//! * **Per-command value resolvers.** The *value* side (what counts as a valid
20//!   value / what to complete) may legitimately differ between commands that
21//!   share one parse definition — `datasets ping --at` and
22//!   `datasets precache --at` parse `--at` identically but might resolve
23//!   different catalog sets. So the resolver is a per-attachment callback, not
24//!   part of the shared definition, and is not subject to the consistency
25//!   check.
26//!
27//! A caller supplies both halves through one [`CommandOption`] trait value: the
28//! DRY [`OptionDef`] plus the callbacks for this attachment point.
29
30use std::collections::HashMap;
31
32use crate::ValueProvider;
33
34/// The parse-defining shape of an option — everything that determines *how the
35/// option is parsed*, and nothing about *what its values are*.
36///
37/// Equality is structural: two attachments of the same option name must
38/// produce equal `OptionDef`s, or the registry rejects them.
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct OptionDef {
41    /// Long flag, including leading dashes, e.g. `"--at"`. This is the unique
42    /// key for the option across the whole project.
43    pub name: String,
44    /// Optional short flag, e.g. `'a'`.
45    pub short: Option<char>,
46    /// Whether the option takes a value (`--at <X>`) vs. a boolean flag.
47    pub takes_value: bool,
48    /// Whether the option may be repeated.
49    pub multiple: bool,
50    /// Value placeholder shown in help (e.g. `"URL"`), when `takes_value`.
51    pub value_name: Option<String>,
52    /// Help text. Part of the shared definition so the same option reads the
53    /// same everywhere.
54    pub help: Option<String>,
55    /// Long tokens (with dashes) of options this one cannot be combined
56    /// with — e.g. an exact `--with-dim` conflicts with the
57    /// `--with-min-dim`/`--with-max-dim` range pair. Declaring on one
58    /// side is enough: the completion bridge and the parser both
59    /// symmetrize. A conflicting flag is withheld from tab-completion
60    /// once its counterpart is on the line, and the pair is rejected
61    /// at parse time.
62    pub conflicts_with: Vec<String>,
63}
64
65impl OptionDef {
66    /// A value-taking option with the given long name.
67    pub fn value(name: impl Into<String>) -> Self {
68        OptionDef {
69            name: name.into(),
70            short: None,
71            takes_value: true,
72            multiple: false,
73            value_name: None,
74            help: None,
75            conflicts_with: Vec::new(),
76        }
77    }
78
79    /// A boolean flag with the given long name.
80    pub fn flag(name: impl Into<String>) -> Self {
81        OptionDef {
82            name: name.into(),
83            short: None,
84            takes_value: false,
85            multiple: false,
86            value_name: None,
87            help: None,
88            conflicts_with: Vec::new(),
89        }
90    }
91
92    /// Declare options this one cannot be combined with, by long
93    /// token (with dashes). One-sided declaration suffices — see the
94    /// field docs on [`OptionDef::conflicts_with`].
95    pub fn conflicts_with(mut self, others: &[&str]) -> Self {
96        for o in others {
97            if !self.conflicts_with.iter().any(|c| c == o) {
98                self.conflicts_with.push((*o).to_string());
99            }
100        }
101        self
102    }
103
104    pub fn short(mut self, c: char) -> Self {
105        self.short = Some(c);
106        self
107    }
108    pub fn multiple(mut self, yes: bool) -> Self {
109        self.multiple = yes;
110        self
111    }
112    pub fn value_name(mut self, n: impl Into<String>) -> Self {
113        self.value_name = Some(n.into());
114        self
115    }
116    pub fn help(mut self, h: impl Into<String>) -> Self {
117        self.help = Some(h.into());
118        self
119    }
120
121    /// The parse-distinguishing signature: the properties that actually change
122    /// *how the option is parsed* — value-taking, repeatable, and the short
123    /// spelling. Display-only fields (`value_name`, `help`) are excluded, so a
124    /// flag that merely documents itself differently across commands is not a
125    /// parse inconsistency.
126    pub fn parse_sig(&self) -> (bool, bool, Option<char>) {
127        (self.takes_value, self.multiple, self.short)
128    }
129}
130
131/// A detected violation of "one parse-definition per option name": the same
132/// option name parses differently in different commands (or disagrees with the
133/// registered definition).
134#[derive(Clone, Debug)]
135pub struct ParseMismatch {
136    pub name: String,
137    /// Every place the name was observed, with its (differing) definition.
138    pub occurrences: Vec<(String, OptionDef)>,
139}
140
141impl std::fmt::Display for ParseMismatch {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        writeln!(
144            f,
145            "option '{}' parses inconsistently across commands (must be one parse-definition):",
146            self.name
147        )?;
148        for (path, def) in &self.occurrences {
149            let (takes_value, multiple, short) = def.parse_sig();
150            writeln!(
151                f,
152                "  {:<28} takes_value={takes_value} multiple={multiple} short={short:?}",
153                if path.is_empty() { "<root>" } else { path }
154            )?;
155        }
156        Ok(())
157    }
158}
159
160/// One attachment of a shared option to a command: the DRY parse definition
161/// plus this command's value resolver.
162///
163/// The same option name may be attached by many commands; they must all return
164/// an equal [`OptionDef`] from [`definition`](CommandOption::definition), but
165/// each may return its own [`value_resolver`](CommandOption::value_resolver).
166pub trait CommandOption {
167    /// The shared, parse-defining shape. Must be identical for a given option
168    /// name across every command that attaches it.
169    fn definition(&self) -> OptionDef;
170
171    /// This command's value completer. `None` means no value completion
172    /// (a boolean flag, or a value with no closed/known set). May differ
173    /// between commands that share the same `definition()`.
174    fn value_resolver(&self) -> Option<ValueProvider> {
175        None
176    }
177}
178
179/// Raised when an option name is attached with a definition that disagrees with
180/// the one already recorded for that name. Surfacing this at attach time is the
181/// runtime-verifiable guarantee that every uniquely-named option parses one way.
182#[derive(Clone, Debug, PartialEq, Eq)]
183pub struct OptionConflict {
184    pub name: String,
185    // Boxed: OptionDef carries several owned collections, and this
186    // error rides in `Result` return slots — keep the Err variant
187    // pointer-sized rather than inflating every call frame.
188    pub existing: Box<OptionDef>,
189    pub attempted: Box<OptionDef>,
190}
191
192impl std::fmt::Display for OptionConflict {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        write!(
195            f,
196            "option '{}' is already defined with a different parse structure; \
197             every command attaching '{}' must use one identical definition.\n  \
198             existing:  {:?}\n  attempted: {:?}",
199            self.name, self.name, self.existing, self.attempted
200        )
201    }
202}
203
204impl std::error::Error for OptionConflict {}
205
206/// The project-wide registry of option *definitions* (the "common structure").
207///
208/// It records one [`OptionDef`] per option name and rejects any attempt to
209/// (re)define that name with a different shape. It does **not** store value
210/// resolvers — those are per-command and attach to nodes directly.
211#[derive(Default, Debug)]
212pub struct OptionRegistry {
213    defs: HashMap<String, OptionDef>,
214}
215
216impl OptionRegistry {
217    pub fn new() -> Self {
218        Self::default()
219    }
220
221    /// Record `def` for its name, or verify it matches the existing record.
222    ///
223    /// * first time a name is seen → recorded.
224    /// * seen again with an **equal** definition → accepted (shared use).
225    /// * seen again with a **different** definition → [`OptionConflict`].
226    pub fn define(&mut self, def: &OptionDef) -> Result<(), OptionConflict> {
227        match self.defs.get(&def.name) {
228            Some(existing) if existing != def => Err(OptionConflict {
229                name: def.name.clone(),
230                existing: Box::new(existing.clone()),
231                attempted: Box::new(def.clone()),
232            }),
233            Some(_) => Ok(()),
234            None => {
235                self.defs.insert(def.name.clone(), def.clone());
236                Ok(())
237            }
238        }
239    }
240
241    /// Validate a [`CommandOption`] attachment and yield its per-command value
242    /// resolver. The definition is checked for consistency; the resolver (which
243    /// may vary per command) is returned for the caller to attach to that
244    /// command's node.
245    pub fn attach(
246        &mut self,
247        opt: &dyn CommandOption,
248    ) -> Result<(OptionDef, Option<ValueProvider>), OptionConflict> {
249        let def = opt.definition();
250        self.define(&def)?;
251        Ok((def, opt.value_resolver()))
252    }
253
254    /// The canonical definition recorded for an option name, if any.
255    pub fn get(&self, name: &str) -> Option<&OptionDef> {
256        self.defs.get(name)
257    }
258
259    /// Number of distinct option names defined.
260    pub fn len(&self) -> usize {
261        self.defs.len()
262    }
263    pub fn is_empty(&self) -> bool {
264        self.defs.is_empty()
265    }
266
267    /// Runtime enforcement of "one parse-definition per option name" against the
268    /// **actual parser**.
269    ///
270    /// `observed` is every `(command_path, OptionDef)` extracted from the real
271    /// command/argument tree (e.g. the clap `Command`). For each option name the
272    /// audit collects the distinct [`parse_sig`](OptionDef::parse_sig)s seen —
273    /// plus the registered canonical definition, if any — and reports a
274    /// [`ParseMismatch`] for every name that has more than one. This catches the
275    /// case the registry's `define` alone cannot: two *commands* declaring the
276    /// same flag with different arities, even though neither went through the
277    /// shared definition.
278    pub fn audit(&self, observed: &[(String, OptionDef)]) -> Vec<ParseMismatch> {
279        let mut by_name: std::collections::BTreeMap<String, Vec<(String, OptionDef)>> =
280            std::collections::BTreeMap::new();
281        for (path, def) in observed {
282            by_name
283                .entry(def.name.clone())
284                .or_default()
285                .push((path.clone(), def.clone()));
286        }
287        let mut mismatches = Vec::new();
288        for (name, mut occs) in by_name {
289            let mut sigs: std::collections::HashSet<(bool, bool, Option<char>)> =
290                occs.iter().map(|(_, d)| d.parse_sig()).collect();
291            // Fold in the registered canonical definition so an observed flag
292            // that disagrees with the shared definition is flagged too.
293            if let Some(reg) = self.defs.get(&name)
294                && sigs.insert(reg.parse_sig()) {
295                    occs.push(("<registered>".to_string(), reg.clone()));
296                }
297            if sigs.len() > 1 {
298                occs.sort_by(|a, b| a.0.cmp(&b.0));
299                mismatches.push(ParseMismatch { name, occurrences: occs });
300            }
301        }
302        mismatches
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    // Two commands sharing one named option, each with its own resolver.
311    struct PingAt;
312    impl CommandOption for PingAt {
313        fn definition(&self) -> OptionDef {
314            OptionDef::value("--at").value_name("URL").help("Pin to a catalog location")
315        }
316        fn value_resolver(&self) -> Option<ValueProvider> {
317            Some(std::sync::Arc::new(|_p: &str, _c: &[&str]| vec!["ping-catalog".to_string()]))
318        }
319    }
320    struct PrecacheAt;
321    impl CommandOption for PrecacheAt {
322        // Same parse definition as PingAt …
323        fn definition(&self) -> OptionDef {
324            OptionDef::value("--at").value_name("URL").help("Pin to a catalog location")
325        }
326        // … but a different value resolver. This is allowed.
327        fn value_resolver(&self) -> Option<ValueProvider> {
328            Some(std::sync::Arc::new(|_p: &str, _c: &[&str]| vec!["precache-catalog".to_string()]))
329        }
330    }
331    // Same name, DIFFERENT parse definition — must be rejected.
332    struct BadAt;
333    impl CommandOption for BadAt {
334        fn definition(&self) -> OptionDef {
335            OptionDef::flag("--at") // boolean, not value-taking → inconsistent
336        }
337    }
338
339    #[test]
340    fn same_name_same_definition_attaches_from_multiple_commands() {
341        let mut reg = OptionRegistry::new();
342        let (d1, r1) = reg.attach(&PingAt).expect("first attach ok");
343        let (d2, r2) = reg.attach(&PrecacheAt).expect("second attach (shared def) ok");
344        assert_eq!(d1, d2, "shared definition");
345        assert_eq!(reg.len(), 1, "one definition recorded for the shared name");
346        // Resolvers differ per command — both present, distinct results.
347        assert_eq!(r1.unwrap()("", &[]), vec!["ping-catalog"]);
348        assert_eq!(r2.unwrap()("", &[]), vec!["precache-catalog"]);
349    }
350
351    #[test]
352    fn same_name_conflicting_definition_is_a_runtime_error() {
353        let mut reg = OptionRegistry::new();
354        reg.attach(&PingAt).expect("first attach ok");
355        // (The Ok type carries a non-Debug `ValueProvider`, so match rather
356        // than `expect_err`.)
357        let err = match reg.attach(&BadAt) {
358            Err(e) => e,
359            Ok(_) => panic!("conflicting --at must error"),
360        };
361        assert_eq!(err.name, "--at");
362        assert!(err.to_string().contains("already defined with a different parse structure"));
363    }
364
365    #[test]
366    fn distinct_names_coexist() {
367        let mut reg = OptionRegistry::new();
368        reg.define(&OptionDef::value("--at")).unwrap();
369        reg.define(&OptionDef::value("--dataset")).unwrap();
370        reg.define(&OptionDef::flag("--recursive")).unwrap();
371        assert_eq!(reg.len(), 3);
372    }
373
374    #[test]
375    fn audit_flags_same_name_with_different_arity_across_commands() {
376        let reg = OptionRegistry::new();
377        // `--at` taken as a repeatable value in one command, a single value in
378        // another — the exact "different clap arities" the registry alone can't
379        // catch.
380        let observed = vec![
381            ("datasets ping".to_string(), OptionDef::value("--at").multiple(true)),
382            ("datasets precache".to_string(), OptionDef::value("--at").multiple(true)),
383            ("config catalog remove".to_string(), OptionDef::value("--at")),
384        ];
385        let mismatches = reg.audit(&observed);
386        assert_eq!(mismatches.len(), 1);
387        assert_eq!(mismatches[0].name, "--at");
388        assert_eq!(mismatches[0].occurrences.len(), 3);
389    }
390
391    #[test]
392    fn audit_passes_when_every_occurrence_parses_identically() {
393        let mut reg = OptionRegistry::new();
394        reg.define(&OptionDef::value("--at").multiple(true)).unwrap();
395        let observed = vec![
396            ("datasets ping".to_string(), OptionDef::value("--at").multiple(true)),
397            ("datasets precache".to_string(), OptionDef::value("--at").multiple(true)),
398        ];
399        assert!(reg.audit(&observed).is_empty());
400    }
401}