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}