veks_completion/lib.rs
1// Copyright (c) Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Dynamic shell completion engine for CLI tools.
5//!
6//! Provides a generic, tree-based completion system that completes one level
7//! at a time (no eager subcommand chaining). The caller defines the command
8//! tree via [`CommandTree`], and this crate handles:
9//!
10//! - Walking the tree to find candidates for a given input
11//! - Filtering out options already present on the command line
12//! - Handling bare `key=value` params alongside `--flag` options
13//! - Dynamic option discovery from command-line context (e.g., reading
14//! a workload file to discover its declared parameters)
15//! - Generating bash completion scripts
16//! - Handling the `_<APP>_COMPLETE=bash` env var callbacks
17//!
18//! # Usage
19//!
20//! ```rust,no_run
21//! use veks_completion::{CommandTree, Node, complete, print_bash_script, handle_complete_env};
22//!
23//! let tree = CommandTree::new("myapp")
24//! .command("run", Node::leaf(&["--dry-run", "--threads"]))
25//! .command("check", Node::leaf(&["--all", "--quiet"]))
26//! .group("pipeline", Node::group(vec![
27//! ("compute", Node::group(vec![
28//! ("knn", Node::leaf(&["--base", "--query", "--metric"])),
29//! ])),
30//! ]));
31//!
32//! // In main():
33//! if handle_complete_env("myapp", &tree) {
34//! std::process::exit(0);
35//! }
36//! ```
37
38use std::collections::BTreeMap;
39
40// Lets `#[derive(VeksCli)]`-generated code (which emits `::veks_completion::…`
41// paths) resolve correctly when used *inside* this crate's own tests.
42extern crate self as veks_completion;
43
44pub mod cli;
45pub mod options;
46pub mod providers;
47
48// NB: `cli::ParseError` is intentionally not re-exported at the crate root —
49// it would collide with the existing completion `ParseError`. Reach it as
50// `veks_completion::cli::ParseError`.
51pub use cli::{render_help, CommandSpec, OptionSpec, ParsedArgs, PositionalSpec, VeksCli};
52pub use options::{CommandOption, OptionConflict, OptionDef, OptionRegistry, ParseMismatch};
53
54/// A function that provides dynamic completion values for a specific option.
55///
56/// Called when the user tabs after an option that has a registered provider.
57/// Receives the partial word being typed and the full context of completed
58/// words on the command line (excluding the program name and the partial).
59/// Heap-allocated, thread-safe closure type so providers can capture
60/// data (e.g., a static enum-value list discovered from a
61/// `CommandOp::value_completions` map). Function pointers can be
62/// promoted to this type via [`ValueProvider::from_fn`] so existing
63/// `fn(&str, &[&str]) -> Vec<String>` providers keep working.
64pub type ValueProvider = std::sync::Arc<dyn Fn(&str, &[&str]) -> Vec<String> + Send + Sync>;
65
66/// Helper to wrap a `fn`-pointer provider into the closure-typed
67/// [`ValueProvider`]. Most existing global providers use plain `fn`
68/// pointers and call this when registering.
69pub fn fn_provider(f: fn(&str, &[&str]) -> Vec<String>) -> ValueProvider {
70 std::sync::Arc::new(f)
71}
72
73/// Build a [`ValueProvider`] over a fixed set of candidate values, filtered by
74/// the typed prefix. This is the completion side of a closed-set option — e.g.
75/// the derive emits it for `#[arg(value_parser = ["text", "csv", "json"])]` so
76/// the same single declaration that drives parsing also feeds tab-completion.
77pub fn closed_set(values: &'static [&'static str]) -> ValueProvider {
78 std::sync::Arc::new(move |partial: &str, _ctx: &[&str]| {
79 values
80 .iter()
81 .filter(|v| partial.is_empty() || v.starts_with(partial))
82 .map(|v| v.to_string())
83 .collect()
84 })
85}
86
87/// A closed set of valid values for a flag. Both static (`&'static
88/// [&'static str]`) and runtime-owned (`Vec<String>`) variants are
89/// supported via the same query API.
90///
91/// Solves two TODO gaps in one type:
92///
93/// - **Item 1** (no per-set glue functions): callers no longer need
94/// to write `fn palette_provider(...) -> Vec<String> { palette.iter()
95/// .filter(...).collect() }` boilerplate per closed set. Just
96/// construct a [`ClosedValues`] and convert to a [`ValueProvider`]
97/// via [`ClosedValues::into_provider`].
98/// - **Item 5** (validation surface): the same declaration that
99/// drives completion can drive parser-side validation —
100/// [`ClosedValues::validate`] returns `true` for any value the
101/// completer would have offered.
102///
103/// ```
104/// use veks_completion::ClosedValues;
105///
106/// let metrics = ClosedValues::Static(&["L2", "IP", "COSINE"]);
107///
108/// // Completion: prefix-filtered.
109/// assert_eq!(metrics.complete(""), vec!["L2", "IP", "COSINE"]);
110/// assert_eq!(metrics.complete("CO"), vec!["COSINE"]);
111///
112/// // Validation: exact set membership.
113/// assert!(metrics.validate("L2"));
114/// assert!(!metrics.validate("bogus"));
115///
116/// // Convert to a ValueProvider for tree registration.
117/// let provider = metrics.clone().into_provider();
118/// assert_eq!(provider("CO", &[]), vec!["COSINE"]);
119/// ```
120#[derive(Debug, Clone)]
121pub enum ClosedValues {
122 /// Borrowed `&'static` slice — preferred when the set is known
123 /// at compile time (the common case).
124 Static(&'static [&'static str]),
125 /// Heap-owned values — for runtime-built specs whose closed set
126 /// isn't known until the binary inspects its environment.
127 Owned(Vec<String>),
128}
129
130impl ClosedValues {
131 /// Iterate over the values as `&str` regardless of variant.
132 pub fn values(&self) -> Vec<&str> {
133 match self {
134 ClosedValues::Static(s) => s.to_vec(),
135 ClosedValues::Owned(v) => v.iter().map(|s| s.as_str()).collect(),
136 }
137 }
138
139 /// Prefix-filtered completion candidates.
140 pub fn complete(&self, partial: &str) -> Vec<String> {
141 match self {
142 ClosedValues::Static(s) => s
143 .iter()
144 .filter(|v| v.starts_with(partial))
145 .map(|v| (*v).to_string())
146 .collect(),
147 ClosedValues::Owned(v) => v
148 .iter()
149 .filter(|val| val.starts_with(partial))
150 .cloned()
151 .collect(),
152 }
153 }
154
155 /// Membership check — `true` iff `value` is in the set.
156 pub fn validate(&self, value: &str) -> bool {
157 match self {
158 ClosedValues::Static(s) => s.contains(&value),
159 ClosedValues::Owned(v) => v.iter().any(|val| val == value),
160 }
161 }
162
163 /// Wrap as a [`ValueProvider`] for use with
164 /// [`Node::with_value_provider`]. The set is moved into
165 /// the closure; clone the [`ClosedValues`] first if you also
166 /// need to keep it for validation.
167 pub fn into_provider(self) -> ValueProvider {
168 std::sync::Arc::new(move |partial: &str, _ctx: &[&str]| self.complete(partial))
169 }
170}
171
172/// Convenience: hand any [`ClosedValues`] to APIs that take a
173/// [`ValueProvider`] without explicit `.into_provider()`.
174impl From<ClosedValues> for ValueProvider {
175 fn from(cv: ClosedValues) -> Self {
176 cv.into_provider()
177 }
178}
179
180/// Discovery-tier abstraction symmetric with [`CategoryTag`].
181///
182/// `veks-completion` doesn't define how many tiers exist or how
183/// they're named — each consuming crate decides. Implement
184/// `LevelTag` on your own enum to declare a closed set of
185/// stratified-completion tiers; commands then return `&'static dyn
186/// LevelTag` and the completion engine orders by [`rank`].
187///
188/// `rank()` is the scalar used by stratified completion (the Nth
189/// tab tap reveals everything with `rank <= N`). Lower = more
190/// discoverable. Two implementors with the same `rank` are treated
191/// as the same tier.
192pub trait LevelTag: 'static + Send + Sync + std::fmt::Debug {
193 /// Numeric tier; lower values are revealed first by the
194 /// stratified-completion tab cycle.
195 fn rank(&self) -> u32;
196
197 /// Optional display name (e.g., "primary", "advanced") for
198 /// help renderers. Default returns the empty string —
199 /// implementors should override for human-friendly listings.
200 fn name(&self) -> &'static str { "" }
201}
202
203/// Discovery-category abstraction.
204///
205/// `veks-completion` doesn't define WHICH categories exist — each
206/// consuming crate decides. Implement `CategoryTag` on your own
207/// enum to declare a closed set of categories specific to your
208/// project; commands then return `&'static dyn CategoryTag`
209/// references and the completion engine groups by `tag()`.
210///
211/// Example:
212/// ```ignore
213/// #[derive(Debug, Clone, Copy)]
214/// enum MyCategory { Foo, Bar }
215/// impl veks_completion::CategoryTag for MyCategory {
216/// fn tag(&self) -> &'static str {
217/// match self { Self::Foo => "foo", Self::Bar => "bar" }
218/// }
219/// }
220/// // Static instances per variant for `&'static dyn` returns:
221/// static CAT_FOO: MyCategory = MyCategory::Foo;
222/// static CAT_BAR: MyCategory = MyCategory::Bar;
223/// ```
224///
225/// `tag()` is the stable, lowercase grouping key. Two implementors
226/// returning the same `tag()` are treated as the same group at
227/// completion time.
228pub trait CategoryTag: 'static + Send + Sync + std::fmt::Debug {
229 /// Stable lowercase tag used by completion grouping and help
230 /// rendering as the user-visible category name.
231 fn tag(&self) -> &'static str;
232}
233
234/// A function that provides additional option candidates based on context.
235///
236/// Called during leaf completion to discover extra `key=` options that
237/// aren't statically declared. For example, reading a workload file
238/// referenced on the command line and returning its declared parameter
239/// names as completable options.
240///
241/// Receives the partial word being typed and the full context of completed
242/// words. Returns additional option names (e.g., `["keyspace=", "table="]`).
243pub type DynamicOptionsProvider = fn(partial: &str, context: &[&str]) -> Vec<String>;
244
245/// Default visibility tier when a node doesn't explicitly opt
246/// into a higher tier. Tier 1 means "show on the very first
247/// tab tap" — preserves the pre-stratification behavior for
248/// existing apps that haven't categorized their commands.
249pub const DEFAULT_LEVEL: u32 = 1;
250
251/// Errors produced by [`CommandTree::validate`].
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub enum MetadataError {
254 /// A registered command lacks a category tag and the tree
255 /// was built with [`CommandTree::require_metadata`].
256 MissingCategory { command: String },
257 /// A registered command lacks an explicit `with_level()`
258 /// call and the tree was built with
259 /// [`CommandTree::require_metadata`].
260 MissingLevel { command: String },
261}
262
263impl std::fmt::Display for MetadataError {
264 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265 match self {
266 MetadataError::MissingCategory { command } =>
267 write!(f, "command '{command}' is missing a category — call \
268 Node::with_category(...) when registering"),
269 MetadataError::MissingLevel { command } =>
270 write!(f, "command '{command}' is missing an explicit level — \
271 call Node::with_level(N) when registering"),
272 }
273 }
274}
275
276impl std::error::Error for MetadataError {}
277
278/// A node in the command tree.
279///
280/// Maturity tier a command can declare, governing whether it is offered during
281/// tab-completion. Ordered least-to-most stable, so the derived `Ord` reads as
282/// "at least this stable": a command is suggested when its stability `>=` the
283/// active [`CommandTree::min_stability`] threshold.
284///
285/// Commands default to [`Stability::Stable`]. The completion threshold defaults
286/// to [`Stability::Preview`], so `Experimental` commands are hidden from
287/// suggestions until the threshold is lowered (e.g. a leading `---experimental`
288/// on the line). Stability only governs what completion *suggests* — every
289/// command remains runnable regardless of its tier.
290#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
291pub enum Stability {
292 /// Early / unstable. Hidden from completion unless the threshold is lowered.
293 Experimental,
294 /// Available for preview. Shown at the default threshold.
295 Preview,
296 /// Production-ready. The default for any command that doesn't declare one.
297 #[default]
298 Stable,
299}
300
301impl Stability {
302 /// Parse a stability name (case-insensitive): `stable`, `preview`, or
303 /// `experimental`. `None` for anything else.
304 pub fn from_name(name: &str) -> Option<Self> {
305 match name.to_ascii_lowercase().as_str() {
306 "stable" => Some(Self::Stable),
307 "preview" => Some(Self::Preview),
308 "experimental" => Some(Self::Experimental),
309 _ => None,
310 }
311 }
312
313 /// The lowercase canonical name.
314 pub fn name(self) -> &'static str {
315 match self {
316 Self::Experimental => "experimental",
317 Self::Preview => "preview",
318 Self::Stable => "stable",
319 }
320 }
321}
322
323/// Extract the completion stability threshold from a line's already-completed
324/// words. A `---stable` / `---preview` / `---experimental` token sets the
325/// threshold (last one wins); `default` applies when none is present. Every
326/// `---…` token is removed from the returned words — they're reserved engine
327/// meta, never subcommands and never offered as candidates.
328fn split_stability_prefix(prior: Vec<String>, default: Stability) -> (Stability, Vec<String>) {
329 let mut threshold = default;
330 let filtered = prior
331 .into_iter()
332 .filter(|w| match w.strip_prefix("---") {
333 Some(rest) => {
334 if let Some(s) = Stability::from_name(rest) {
335 threshold = s;
336 }
337 false
338 }
339 None => true,
340 })
341 .collect();
342 (threshold, filtered)
343}
344
345/// Carries two metadata fields used by stratified
346/// (multi-tap) completion:
347///
348/// - `category` — free-form display tag. Apps can group root
349/// commands by category in expanded help / future renderers
350/// (e.g. `workloads`, `documentation`, `tools`).
351/// - `level` — visibility tier. The Nth tab tap reveals every
352/// root-level node with `level <= N`. Default
353/// [`DEFAULT_LEVEL`] (= 1) means "always shown from the
354/// first tap." Use a higher level (2, 3, …) for
355/// less-discoverable commands so the first tap stays focused
356/// on a small set the user wants by default.
357///
358/// Existing callers that didn't set these fields get the
359/// pre-existing behavior automatically (everything visible at
360/// tap 1, no category metadata).
361///
362/// A node in the command tree. Carries everything a command-tree
363/// node *can* have: subcommand children, flags, providers,
364/// discovery metadata, help text, and a free-form attachment slot.
365///
366/// "Leaf" and "Group" are no longer separate variants. A node with
367/// no children is leaf-shaped; a node with children is group-shaped;
368/// a node with both is hybrid (e.g., `report --workload x base`,
369/// where `report` accepts `--workload` *and* has a `base` subcommand).
370/// Walkers branch on `children.is_empty()` only when the distinction
371/// actually matters.
372///
373/// All builder methods (`with_*`) return `self` so they chain.
374/// Methods that operate on children (e.g. `with_child`) work on
375/// any node — calling them just adds the child, regardless of
376/// whether the node was previously leaf-shaped or not.
377#[derive(Clone)]
378#[derive(Default)]
379pub struct Node {
380 // ---- discovery / display ----
381 /// Display group tag — see [`CategoryTag`] for usage.
382 category: Option<String>,
383 /// Maturity tier — see [`Stability`]. Governs whether this command is
384 /// offered during completion (vs. the active threshold). Default `Stable`.
385 stability: Stability,
386 /// Tap-tier visibility. `None` ⇒ "never explicitly set"; the
387 /// effective level resolves to [`DEFAULT_LEVEL`], but
388 /// strict-metadata mode treats `None` as missing.
389 level: Option<u32>,
390 /// One-line `--help` summary. Set via [`Node::with_help`].
391 help: Option<String>,
392
393 // ---- subcommand children ----
394 /// Named children. Empty ⇒ leaf-shaped.
395 children: BTreeMap<String, Node>,
396
397 // ---- flags this node accepts ----
398 /// All flag names (value-taking + boolean), in declared order.
399 flags: Vec<String>,
400 /// Subset of `flags` that don't take a value.
401 boolean_flags: std::collections::HashSet<String>,
402 /// Short-flag aliases: `-c` → `--config`. Every flag-shaped
403 /// lookup resolves through [`Node::resolve_flag_token`] so a
404 /// short previous word value-completes exactly like its long
405 /// form. Registration-keyed on purpose: an unregistered
406 /// single-dash word (a negative number argument, say `-5`)
407 /// resolves to nothing and is never mistaken for a flag.
408 short_aliases: BTreeMap<String, String>,
409 /// Per-flag help text. Used by [`render_usage`].
410 flag_help: BTreeMap<String, String>,
411 /// Extended help text for flags — shown on triple-tap at a
412 /// value position when present. Mirrors clap's
413 /// `Arg::long_help`. Falls back to `flag_help` if no extended
414 /// text was registered.
415 flag_long_help: BTreeMap<String, String>,
416 /// Dynamic value providers keyed by flag name.
417 value_providers: BTreeMap<String, ValueProvider>,
418 /// Mutual-exclusion sets: flag → flags it cannot be combined
419 /// with. Symmetric by construction (the completion bridge
420 /// registers both directions). A flag whose conflict is already
421 /// on the line is withheld from completion.
422 flag_conflicts: BTreeMap<String, Vec<String>>,
423 /// How many positional slots this command's provider serves.
424 /// 1 (the default) preserves the original first-positional-only
425 /// behavior; a command like `config set <key> <value>` sets 2
426 /// and its provider inspects the already-entered positionals to
427 /// decide which slot it is completing.
428 positional_slots: usize,
429 /// Provider for this command's first positional argument (e.g. the backend
430 /// name in `backends remove <name>`). Consulted when the cursor sits at a
431 /// bare positional slot rather than after a value flag.
432 positional_provider: Option<ValueProvider>,
433
434 // ---- discovery extras ----
435 /// Optional provider that discovers additional `key=` options
436 /// from context (e.g., workload-file parameters).
437 dynamic_options: Option<DynamicOptionsProvider>,
438 /// Context-aware completion override that fires whenever the
439 /// cursor sits inside this subtree.
440 subtree_provider: Option<SubtreeProvider>,
441 /// Free-form attachment slot. Downstream crates use this to
442 /// carry handler payloads, parser state, dispatch rules, etc.,
443 /// without forcing this crate to grow generics.
444 extras: Option<Extras>,
445}
446
447/// Type alias for group-level context-aware completion providers
448/// (TODO item 7). Receives a structured [`PartialParse`] of the
449/// command line state and returns candidates to merge into the
450/// completer's output.
451pub type SubtreeProvider =
452 std::sync::Arc<dyn Fn(&PartialParse) -> Vec<String> + Send + Sync>;
453
454/// Free-form payload slot on a Node (TODO item 8). Wraps an
455/// `Arc<dyn Any + Send + Sync>` so embedders can attach handler
456/// types, parser state, or anything else without forcing
457/// veks-completion to grow generic parameters or hard dependencies.
458///
459/// Recover the payload via `Arc::downcast` on the inner Arc.
460#[derive(Clone)]
461pub struct Extras(pub std::sync::Arc<dyn std::any::Any + Send + Sync>);
462
463impl std::fmt::Debug for Extras {
464 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
465 f.debug_struct("Extras").field("type_id", &self.0.type_id()).finish()
466 }
467}
468
469impl Extras {
470 /// Wrap any `Send + Sync + 'static` value as an extras payload.
471 pub fn new<T: std::any::Any + Send + Sync + 'static>(value: T) -> Self {
472 Extras(std::sync::Arc::new(value))
473 }
474
475 /// Try to downcast to a concrete type. Returns `None` if the
476 /// payload was attached as a different type.
477 pub fn downcast<T: std::any::Any + Send + Sync + 'static>(
478 &self,
479 ) -> Option<&T> {
480 self.0.downcast_ref::<T>()
481 }
482}
483
484/// Structured snapshot of the partial command-line state at the
485/// cursor position. Passed to subtree providers so they can offer
486/// context-aware completions without re-tokenising `COMP_LINE`.
487///
488/// Carries both the **whitespace-tokenised** view (`completed`,
489/// `partial`, `tree_path` — the same shape every veks-completion
490/// flow uses) AND the **raw line + cursor offset** that grammar-aware
491/// providers (e.g. for embedded query DSLs like MetricsQL or PromQL)
492/// need to resolve quote / bracket / operator state. Callers that
493/// don't have raw context populate `raw_line` with an empty string
494/// and `cursor_offset` with `0` — grammar helpers fall back to the
495/// tokenised view in that case.
496#[derive(Debug, Clone)]
497pub struct PartialParse<'a> {
498 /// Words the user has already completed (whitespace-separated,
499 /// program name excluded).
500 pub completed: Vec<&'a str>,
501 /// The partial word currently under the cursor (may be empty).
502 pub partial: &'a str,
503 /// Path through the command tree that resolved against
504 /// `completed`. Same shape as `completed` but only the prefix
505 /// that maps to actual nodes.
506 pub tree_path: Vec<&'a str>,
507 /// Raw `COMP_LINE` (or equivalent) — the full command line as
508 /// the user typed it, before any tokenisation. Empty when the
509 /// caller didn't have it.
510 pub raw_line: &'a str,
511 /// Byte offset of the cursor within `raw_line`. `0` when
512 /// `raw_line` is empty.
513 pub cursor_offset: usize,
514 /// Tap count for this completion invocation. The engine sets
515 /// this from the rotating-tier counter (`1` on the first tap,
516 /// `2` on a rapid follow-up, etc.). Providers may use it to
517 /// layer their output — e.g., tap 1 = primary candidates
518 /// (metric names inside a function-arg position), tap 2 = +
519 /// secondary candidates (inner functions to stack). Defaults
520 /// to `1` for callers that don't drive rotation.
521 pub tap_count: u32,
522}
523
524/// Bracket / quote depth at the cursor, computed by
525/// [`PartialParse::bracket_state`]. Lets a grammar-aware provider
526/// answer "am I inside a `{...}`, `(...)`, `[...]`, or quoted
527/// string?" without re-implementing the scanner.
528#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
529pub struct BracketState {
530 /// Net `(` minus `)` count up to the cursor. Negative ⇒ extra
531 /// closes (likely user error).
532 pub paren: i32,
533 /// Net `{` minus `}` count up to the cursor.
534 pub brace: i32,
535 /// Net `[` minus `]` count up to the cursor.
536 pub bracket: i32,
537 /// `Some(quote)` when the cursor sits inside an unclosed
538 /// quote of the indicated kind (`"` or `'`); `None` otherwise.
539 pub inside_quote: Option<char>,
540}
541
542impl<'a> PartialParse<'a> {
543 /// Slice of `raw_line` strictly before the cursor. Empty when
544 /// `raw_line` is empty.
545 pub fn before_cursor(&self) -> &'a str {
546 if self.raw_line.is_empty() { return ""; }
547 &self.raw_line[..self.cursor_offset.min(self.raw_line.len())]
548 }
549
550 /// Slice of `raw_line` from the cursor to the end.
551 pub fn after_cursor(&self) -> &'a str {
552 if self.raw_line.is_empty() { return ""; }
553 &self.raw_line[self.cursor_offset.min(self.raw_line.len())..]
554 }
555
556 /// Compute the bracket / quote state at the cursor by linearly
557 /// scanning `before_cursor`. Honors quotes (everything inside
558 /// `"…"` or `'…'` is counted as string content, brackets within
559 /// don't shift the depth) and supports backslash-escapes inside
560 /// quotes.
561 ///
562 /// When `raw_line` is empty (caller didn't supply it), returns
563 /// the default zero-depth state.
564 pub fn bracket_state(&self) -> BracketState {
565 let s = self.before_cursor();
566 let mut state = BracketState::default();
567 let mut chars = s.chars().peekable();
568 while let Some(c) = chars.next() {
569 if let Some(q) = state.inside_quote {
570 if c == '\\' {
571 // Skip the next character (escaped).
572 chars.next();
573 continue;
574 }
575 if c == q {
576 state.inside_quote = None;
577 }
578 continue;
579 }
580 match c {
581 '(' => state.paren += 1,
582 ')' => state.paren -= 1,
583 '{' => state.brace += 1,
584 '}' => state.brace -= 1,
585 '[' => state.bracket += 1,
586 ']' => state.bracket -= 1,
587 '"' | '\'' => state.inside_quote = Some(c),
588 _ => {}
589 }
590 }
591 state
592 }
593
594 /// Last non-whitespace, non-identifier character before the
595 /// cursor, scanning back over identifier characters first.
596 /// Useful for "what symbol triggered this completion?" — e.g.,
597 /// `=` after `label` means we're in a label-value position.
598 /// Returns `None` if the only thing before the cursor is
599 /// identifier characters or whitespace.
600 pub fn trigger_char(&self) -> Option<char> {
601 let s = self.before_cursor();
602 let mut chars = s.chars().rev();
603 // Skip current identifier-ish run.
604 while let Some(c) = chars.clone().next() {
605 if is_ident_char(c) {
606 chars.next();
607 } else {
608 break;
609 }
610 }
611 chars.next()
612 }
613
614 /// `COMP_WORDBREAKS` value that the engine's bash hook
615 /// installs locally — a deliberately minimal set that keeps
616 /// shell metacharacters as word separators (`< > ; | &`) but
617 /// drops everything bash would otherwise use to "helpfully"
618 /// split inside a grammar token: `' "` (shell wrapper quotes),
619 /// `=` (key=value), `(` (function-call open), `:` (label
620 /// values, subquery step). With these out of the way:
621 ///
622 /// - `'metricsql expr` is one word → readline doesn't
623 /// auto-close the wrapper quote when our candidate ends
624 /// mid-context (e.g. `delta(`).
625 /// - `up{job=` is one word → label-value candidates splice
626 /// cleanly without prefix gymnastics.
627 /// - `delta(rate(` is one word → nested function-call
628 /// completions work without the shell mid-quoting.
629 ///
630 /// This is the bash-side "raw mode" the engine relies on so
631 /// shell-quoting heuristics don't fight grammar-aware splicing.
632 /// The hook sets it locally per call so the user's interactive
633 /// `COMP_WORDBREAKS` is untouched outside completion.
634 ///
635 /// `{`, `[`, `]`, `}`, `,` were already not in bash's default
636 /// set — they don't split the word in bash, which is what makes
637 /// [`PartialParse::splice_candidate`] necessary in the first
638 /// place. We additionally strip `' " = ( :` to extend the same
639 /// "splicer owns this" treatment to those grammar contexts.
640 pub const DEFAULT_BASH_WORDBREAKS: &'static str = " \t\n<>;|&";
641
642 /// Byte offset in [`Self::raw_line`] where the shell will
643 /// consider the "current word" to begin — the byte after the
644 /// last word-separator character before the cursor, or `0` if
645 /// none. Falls back to `0` when `raw_line` is empty.
646 ///
647 /// Today this assumes bash semantics
648 /// ([`Self::DEFAULT_BASH_WORDBREAKS`]). When other shells gain
649 /// first-class support, this method may take a per-shell
650 /// wordbreak set.
651 pub fn shell_word_start(&self) -> usize {
652 let before = self.before_cursor();
653 before
654 .rfind(|c: char| Self::DEFAULT_BASH_WORDBREAKS.contains(c))
655 .map(|p| p + 1)
656 .unwrap_or(0)
657 }
658
659 /// What the shell sees as the "current word" — the slice
660 /// between [`Self::shell_word_start`] and the cursor. This is
661 /// the segment the shell will *replace* when the user accepts
662 /// a candidate the engine returns.
663 pub fn shell_current_word(&self) -> &'a str {
664 &self.raw_line[self.shell_word_start().min(self.raw_line.len())
665 ..self.cursor_offset.min(self.raw_line.len())]
666 }
667
668 /// Splice a *logical* suggestion into the shell-correct form
669 /// for a COMPREPLY candidate. Providers compute suggestions in
670 /// their own grammar terms (e.g. "the label key is `job`");
671 /// this helper produces the substitution string the shell
672 /// needs so that whatever the user already typed in the
673 /// shell-perceived current word, *before* the suggestion's
674 /// insertion point, is preserved.
675 ///
676 /// `target_start` is the byte offset in [`Self::raw_line`]
677 /// where the suggestion logically begins. For an inside-`{`
678 /// label-key suggestion in `up{job`, `target_start` is the
679 /// position right after the `{`. The helper returns
680 /// `raw_line[shell_word_start..target_start] + suggestion` —
681 /// i.e., the part of the shell's current word that's BEFORE
682 /// the completion target, plus the new content.
683 ///
684 /// When `target_start <= shell_word_start`, the suggestion
685 /// replaces the entire shell word (or starts before it), so
686 /// the helper returns the suggestion unchanged.
687 ///
688 /// # Example
689 ///
690 /// ```
691 /// use veks_completion::PartialParse;
692 ///
693 /// let pp = PartialParse {
694 /// completed: vec![],
695 /// partial: "",
696 /// tree_path: vec![],
697 /// raw_line: "myapp query up{",
698 /// cursor_offset: 15,
699 /// tap_count: 1,
700 /// };
701 /// assert_eq!(pp.shell_word_start(), 12); // after the last space
702 /// assert_eq!(pp.shell_current_word(), "up{"); // shell will replace this
703 ///
704 /// // Suggestion: the label key `job`. Logically it starts
705 /// // right after the `{` (byte 15).
706 /// let candidate = pp.splice_candidate(15, "job");
707 /// assert_eq!(candidate, "up{job");
708 /// // → shell replaces "up{" with "up{job" ⇒ final word "up{job"
709 /// ```
710 pub fn splice_candidate(&self, target_start: usize, suggestion: &str) -> String {
711 let sws = self.shell_word_start();
712 if target_start <= sws {
713 suggestion.to_string()
714 } else {
715 let end = target_start.min(self.raw_line.len());
716 let prefix = &self.raw_line[sws..end];
717 format!("{prefix}{suggestion}")
718 }
719 }
720
721 /// Identifier (or partial identifier) immediately to the left
722 /// of the cursor. For input `up{job=`, returns `""` (cursor is
723 /// right after `=`, so the partial-ident before the cursor is
724 /// empty). For input `up{jo`, returns `"jo"`.
725 pub fn ident_before_cursor(&self) -> &'a str {
726 let s = self.before_cursor();
727 let bytes = s.as_bytes();
728 let mut i = bytes.len();
729 while i > 0 {
730 let c = bytes[i - 1] as char;
731 if is_ident_char(c) {
732 i -= 1;
733 } else {
734 break;
735 }
736 }
737 &s[i..]
738 }
739}
740
741#[inline]
742fn is_ident_char(c: char) -> bool {
743 c.is_ascii_alphanumeric() || c == '_' || c == ':'
744}
745
746impl std::fmt::Debug for Node {
747 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
748 f.debug_struct("Node")
749 .field("category", &self.category)
750 .field("level", &self.level)
751 .field("help", &self.help)
752 .field("children", &self.children)
753 .field("flags", &self.flags)
754 .field("boolean_flags", &self.boolean_flags)
755 .field("flag_help", &self.flag_help.keys().collect::<Vec<_>>())
756 .field("value_providers", &self.value_providers.keys().collect::<Vec<_>>())
757 .field("has_dynamic_options", &self.dynamic_options.is_some())
758 .field("has_subtree_provider", &self.subtree_provider.is_some())
759 .field("has_extras", &self.extras.is_some())
760 .finish()
761 }
762}
763
764
765impl Node {
766 /// Empty node — no flags, no children, no metadata. Build up
767 /// from here using the `with_*` builders.
768 pub fn new() -> Self { Self::default() }
769
770 /// Convenience: a leaf-shaped node carrying the supplied
771 /// value-taking flags (none of them booleans).
772 pub fn leaf(flags: &[&str]) -> Self {
773 Node {
774 flags: flags.iter().map(|s| s.to_string()).collect(),
775 ..Self::default()
776 }
777 }
778
779 /// Convenience: a leaf-shaped node with separate value-taking
780 /// and boolean flag lists.
781 pub fn leaf_with_flags(value_flags: &[&str], boolean_flags: &[&str]) -> Self {
782 let all: Vec<String> = value_flags.iter()
783 .chain(boolean_flags.iter())
784 .map(|s| s.to_string())
785 .collect();
786 Node {
787 flags: all,
788 boolean_flags: boolean_flags.iter().map(|s| s.to_string()).collect(),
789 ..Self::default()
790 }
791 }
792
793 /// Convenience: a group-shaped node from a list of `(name, child)`
794 /// pairs. Add flags to the group separately via [`Node::with_flags`]
795 /// / [`Node::with_boolean_flags`].
796 pub fn group(children: Vec<(&str, Node)>) -> Self {
797 Node {
798 children: children.into_iter()
799 .map(|(k, v)| (k.to_string(), v))
800 .collect(),
801 ..Self::default()
802 }
803 }
804
805 /// Empty group — no children, no flags. Add via [`Node::with_child`].
806 pub fn empty_group() -> Self { Self::default() }
807
808 // ---- shape predicates -----------------------------------------
809
810 /// Leaf-shaped if it has no children. A node with both flags AND
811 /// children is *not* leaf-shaped — it's hybrid.
812 pub fn is_leaf(&self) -> bool { self.children.is_empty() }
813
814 /// Group-shaped if it has at least one child.
815 pub fn is_group(&self) -> bool { !self.children.is_empty() }
816
817 // ---- children -------------------------------------------------
818
819 /// Add a child to this node. Works on any node; if the node was
820 /// previously leaf-shaped, this turns it into a hybrid (flags +
821 /// children).
822 pub fn with_child(mut self, name: &str, child: Node) -> Self {
823 self.children.insert(name.to_string(), child);
824 self
825 }
826
827 /// Direct access to children (empty for leaf-shaped nodes).
828 pub fn children(&self) -> &BTreeMap<String, Node> { &self.children }
829
830 /// Mutable access to children — used by internal walkers.
831 pub fn children_mut(&mut self) -> &mut BTreeMap<String, Node> { &mut self.children }
832
833 /// Names of this node's children, in `BTreeMap` order.
834 pub fn child_names(&self) -> Vec<&str> {
835 self.children.keys().map(|k| k.as_str()).collect()
836 }
837
838 /// Look up a child by name.
839 pub fn child(&self, name: &str) -> Option<&Node> {
840 self.children.get(name)
841 }
842
843 // ---- flags ----------------------------------------------------
844
845 /// Add value-taking flags to this node. Idempotent — duplicates
846 /// are skipped.
847 pub fn with_flags(mut self, flags: &[&str]) -> Self {
848 for f in flags {
849 if !self.flags.iter().any(|x| x == f) {
850 self.flags.push((*f).to_string());
851 }
852 }
853 self
854 }
855
856 /// Add boolean flags (no value expected) to this node. Idempotent.
857 pub fn with_boolean_flags(mut self, flags: &[&str]) -> Self {
858 for f in flags {
859 if !self.flags.iter().any(|x| x == f) {
860 self.flags.push((*f).to_string());
861 }
862 self.boolean_flags.insert((*f).to_string());
863 }
864 self
865 }
866
867 /// Register a short alias (`-c`) for a long flag (`--config`).
868 /// The alias participates in value-position detection and value
869 /// completion exactly like the long form.
870 pub fn with_short_alias(mut self, short: &str, long: &str) -> Self {
871 self.short_aliases.insert(short.to_string(), long.to_string());
872 self
873 }
874
875 /// Resolve a previous-word token to the canonical long-flag form
876 /// used as the key for `boolean_flags`, `value_providers`, and
877 /// flag help. `--long` passes through verbatim; a registered
878 /// short alias maps to its long flag; everything else — including
879 /// single-dash words that are values, not flags (`-5`) — is
880 /// `None` for shorts and pass-through-by-shape for longs.
881 pub(crate) fn resolve_flag_token<'a>(&'a self, word: &'a str) -> Option<&'a str> {
882 if word.contains('=') { return None; }
883 if word.starts_with("--") { return Some(word); }
884 self.short_aliases.get(word).map(|s| s.as_str())
885 }
886
887 /// All flag names this node accepts (value-taking + boolean), in
888 /// declared order.
889 pub fn flags(&self) -> &[String] { &self.flags }
890
891 /// Returns `true` if `flag` is a boolean flag on this node.
892 pub fn is_flag(&self, flag: &str) -> bool {
893 self.boolean_flags.contains(flag)
894 }
895
896 /// Convenience accessor — same as [`Node::flags`] but as a `Vec<&str>`.
897 /// Kept for callers that prefer the `&str` view.
898 pub fn options(&self) -> Vec<&str> {
899 self.flags.iter().map(|s| s.as_str()).collect()
900 }
901
902 /// Attach a value provider to one of this node's flags.
903 pub fn with_value_provider(mut self, flag: &str, provider: ValueProvider) -> Self {
904 self.value_providers.insert(flag.to_string(), provider);
905 self
906 }
907
908 /// Declare that `flag` cannot be combined with `conflicts` —
909 /// once `conflicts` is on the line, `flag` is withheld from
910 /// completion. One direction only; callers wanting symmetric
911 /// exclusion (the normal case — see
912 /// [`cli::build_completion_tree`]) register both directions.
913 pub fn with_flag_conflict(mut self, flag: &str, conflicts: &str) -> Self {
914 let entry = self.flag_conflicts.entry(flag.to_string()).or_default();
915 if !entry.iter().any(|c| c == conflicts) {
916 entry.push(conflicts.to_string());
917 }
918 self
919 }
920
921 /// Attach the same value provider to every name in `aliases` —
922 /// e.g. `--tofile` and `--to-file`.
923 pub fn with_value_provider_aliases(
924 mut self,
925 aliases: &[&str],
926 provider: ValueProvider,
927 ) -> Self {
928 for name in aliases {
929 self.value_providers.insert((*name).to_string(), provider.clone());
930 }
931 self
932 }
933
934 /// Direct access to the value-provider map (used by walkers).
935 pub fn value_providers(&self) -> &BTreeMap<String, ValueProvider> {
936 &self.value_providers
937 }
938
939 /// Attach a provider for this command's first positional argument.
940 pub fn with_positional_provider(mut self, provider: ValueProvider) -> Self {
941 if self.positional_slots == 0 {
942 self.positional_slots = 1;
943 }
944 self.positional_provider = Some(provider);
945 self
946 }
947
948 /// The first-positional provider, if any.
949 pub fn positional_provider(&self) -> Option<&ValueProvider> {
950 self.positional_provider.as_ref()
951 }
952
953 /// Declare how many positional slots the provider serves. The
954 /// provider receives the completed words and decides per slot.
955 pub fn with_positional_slots(mut self, slots: usize) -> Self {
956 self.positional_slots = slots.max(1);
957 self
958 }
959
960 /// Attach a dynamic options provider.
961 ///
962 /// The provider is called during completion to discover
963 /// additional `key=` options from context (e.g., workload-file
964 /// parameters).
965 pub fn with_dynamic_options(mut self, provider: DynamicOptionsProvider) -> Self {
966 self.dynamic_options = Some(provider);
967 self
968 }
969
970 /// The attached dynamic options provider, if any.
971 pub fn dynamic_options(&self) -> Option<DynamicOptionsProvider> {
972 self.dynamic_options
973 }
974
975 // ---- discovery / display --------------------------------------
976
977 /// Tag this node with a display category.
978 pub fn with_category(mut self, cat: &str) -> Self {
979 self.category = Some(cat.to_string());
980 self
981 }
982
983 /// Get the node's category tag, if any.
984 pub fn category(&self) -> Option<&str> { self.category.as_deref() }
985
986 /// Declare this command's maturity tier (see [`Stability`]). Controls
987 /// whether it's offered during completion at the active threshold.
988 pub fn with_stability(mut self, stability: Stability) -> Self {
989 self.stability = stability;
990 self
991 }
992
993 /// This node's maturity tier (default [`Stability::Stable`]).
994 pub fn stability(&self) -> Stability { self.stability }
995
996 /// Set the tap-tier visibility for this node.
997 pub fn with_level(mut self, lvl: u32) -> Self {
998 self.level = Some(lvl);
999 self
1000 }
1001
1002 /// Effective tap-tier level — explicit value if set, otherwise
1003 /// [`DEFAULT_LEVEL`].
1004 pub fn level(&self) -> u32 {
1005 self.level.unwrap_or(DEFAULT_LEVEL)
1006 }
1007
1008 /// Explicit tap-tier level — `None` when `with_level` was never
1009 /// called. Used by strict-metadata validation.
1010 pub fn level_explicit(&self) -> Option<u32> { self.level }
1011
1012 // ---- help -----------------------------------------------------
1013
1014 /// Attach a one-line help summary.
1015 pub fn with_help(mut self, text: &str) -> Self {
1016 self.help = Some(text.to_string());
1017 self
1018 }
1019
1020 /// Get the node's help text, if any.
1021 pub fn help(&self) -> Option<&str> { self.help.as_deref() }
1022
1023 /// Attach help text for one of this node's flags.
1024 pub fn with_flag_help(mut self, flag: &str, help: &str) -> Self {
1025 self.flag_help.insert(flag.to_string(), help.to_string());
1026 self
1027 }
1028
1029 /// Builder: register extended help for a flag — surfaced on a
1030 /// rapid triple-tap at the value position. Mirrors clap's
1031 /// `Arg::long_help` shape.
1032 pub fn with_flag_long_help(mut self, flag: &str, help: &str) -> Self {
1033 self.flag_long_help.insert(flag.to_string(), help.to_string());
1034 self
1035 }
1036
1037 /// Lookup extended help for a flag.
1038 pub fn flag_long_help_for(&self, flag: &str) -> Option<&str> {
1039 self.flag_long_help.get(flag).map(|s| s.as_str())
1040 }
1041
1042 /// Get help text for one of this node's flags, if any.
1043 pub fn flag_help_for(&self, flag: &str) -> Option<&str> {
1044 self.flag_help.get(flag).map(|s| s.as_str())
1045 }
1046
1047
1048 // ---- subtree provider -----------------------------------------
1049
1050 /// Attach a context-aware completion override for this subtree.
1051 pub fn with_subtree_provider(mut self, provider: SubtreeProvider) -> Self {
1052 self.subtree_provider = Some(provider);
1053 self
1054 }
1055
1056 /// The subtree provider, if any.
1057 pub fn subtree_provider(&self) -> Option<&SubtreeProvider> {
1058 self.subtree_provider.as_ref()
1059 }
1060
1061 // ---- extras ---------------------------------------------------
1062
1063 /// Attach a free-form payload (handler, parser state, etc.).
1064 pub fn with_extras(mut self, extras: Extras) -> Self {
1065 self.extras = Some(extras);
1066 self
1067 }
1068
1069 /// The attached extras payload, if any.
1070 pub fn extras(&self) -> Option<&Extras> { self.extras.as_ref() }
1071}
1072
1073/// Render a `--help`-style usage block for a node at the given path
1074/// (TODO item 6). The same model that drives tab completion drives
1075/// help, so the two surfaces can't drift.
1076///
1077/// Output format:
1078///
1079/// ```text
1080/// USAGE: <path>
1081///
1082/// <help text>
1083///
1084/// FLAGS:
1085/// --foo Help text for --foo
1086/// --bar (no help)
1087///
1088/// SUBCOMMANDS:
1089/// sub-a Help text for sub-a
1090/// sub-b Help text for sub-b
1091/// ```
1092///
1093/// Sections are omitted when their content is empty. Children are
1094/// listed in `BTreeMap` order (alphabetical).
1095pub fn render_usage(node: &Node, path: &[&str]) -> String {
1096 let mut out = String::new();
1097 out.push_str(&format!("USAGE: {}\n", path.join(" ")));
1098 if let Some(help) = node.help() {
1099 out.push('\n');
1100 out.push_str(help);
1101 out.push('\n');
1102 }
1103
1104 // Flags section.
1105 if !node.flags.is_empty() {
1106 out.push_str("\nFLAGS:\n");
1107 let width = node.flags.iter().map(|f| f.len()).max().unwrap_or(0);
1108 for f in &node.flags {
1109 let h = node.flag_help_for(f).unwrap_or("");
1110 out.push_str(&format!(" {:width$} {}\n", f, h, width = width));
1111 }
1112 }
1113
1114 // Subcommands section.
1115 if !node.children.is_empty() {
1116 out.push_str("\nSUBCOMMANDS:\n");
1117 let width = node.children.keys().map(|k| k.len()).max().unwrap_or(0);
1118 for (name, child) in &node.children {
1119 let h = child.help().unwrap_or("");
1120 out.push_str(&format!(" {:width$} {}\n", name, h, width = width));
1121 }
1122 }
1123
1124 out
1125}
1126
1127// =====================================================================
1128// Strict-metadata builder (compile-time enforcement)
1129// =====================================================================
1130
1131/// Type-state wrapper around [`Node`] that tracks at the type
1132/// level whether the node has been given a category and an
1133/// explicit tap-tier level.
1134///
1135/// Used together with [`CommandTree::strict_command`] /
1136/// [`CommandTree::strict_group`] to force compile-time
1137/// enforcement of the stratified completion contract: an app
1138/// that opts into strict mode cannot register an uncategorized
1139/// or unleveled command, because the registration call itself
1140/// will not type-check unless both fields have been provided.
1141///
1142/// The two `bool` const generics flip from `false` to `true`
1143/// when the matching builder method is called:
1144///
1145/// - `with_category("…")` → `HAS_CATEGORY = true`
1146/// - `with_level(N)` → `HAS_LEVEL = true`
1147///
1148/// Apps that don't want the compile-time check can keep using
1149/// the regular [`Node`] API and call
1150/// [`CommandTree::require_metadata`] to get an equivalent
1151/// runtime check at registration time.
1152///
1153/// # Successful registration (compiles)
1154///
1155/// ```
1156/// use veks_completion::{CommandTree, StrictNode};
1157/// let tree = CommandTree::new("myapp")
1158/// .strict_command(
1159/// "run",
1160/// StrictNode::leaf(&["--cycles=", "--threads="])
1161/// .with_category("workloads")
1162/// .with_level(1),
1163/// );
1164/// # let _ = tree;
1165/// ```
1166///
1167/// # Missing category (compile error)
1168///
1169/// ```compile_fail
1170/// use veks_completion::{CommandTree, StrictNode};
1171/// let _tree = CommandTree::new("myapp").strict_command(
1172/// "bad",
1173/// StrictNode::leaf(&[]).with_level(1), // missing with_category
1174/// );
1175/// ```
1176///
1177/// # Missing level (compile error)
1178///
1179/// ```compile_fail
1180/// use veks_completion::{CommandTree, StrictNode};
1181/// let _tree = CommandTree::new("myapp").strict_command(
1182/// "bad",
1183/// StrictNode::leaf(&[]).with_category("x"), // missing with_level
1184/// );
1185/// ```
1186pub struct StrictNode<const HAS_CATEGORY: bool, const HAS_LEVEL: bool> {
1187 inner: Node,
1188}
1189
1190impl StrictNode<false, false> {
1191 /// Begin building a strict leaf node. Both `with_category`
1192 /// and `with_level` must be called before this can be
1193 /// passed to [`CommandTree::strict_command`].
1194 pub fn leaf(options: &[&str]) -> Self {
1195 Self { inner: Node::leaf(options) }
1196 }
1197
1198 /// Same as [`Node::leaf_with_flags`] but type-state-checked.
1199 pub fn leaf_with_flags(options: &[&str], flags: &[&str]) -> Self {
1200 Self { inner: Node::leaf_with_flags(options, flags) }
1201 }
1202
1203 /// Begin building a strict group node.
1204 pub fn group(children: Vec<(&str, Node)>) -> Self {
1205 Self { inner: Node::group(children) }
1206 }
1207
1208 /// Begin from an already-constructed [`Node`]. Useful when
1209 /// migrating an existing tree to strict mode incrementally.
1210 pub fn from_node(node: Node) -> Self {
1211 Self { inner: node }
1212 }
1213}
1214
1215impl<const C: bool, const L: bool> StrictNode<C, L> {
1216 /// Tag with a category. Flips `HAS_CATEGORY` to `true`.
1217 pub fn with_category(self, cat: &str) -> StrictNode<true, L> {
1218 StrictNode { inner: self.inner.with_category(cat) }
1219 }
1220
1221 /// Set the tap-tier level. Flips `HAS_LEVEL` to `true`.
1222 pub fn with_level(self, lvl: u32) -> StrictNode<C, true> {
1223 StrictNode { inner: self.inner.with_level(lvl) }
1224 }
1225
1226 /// Forward through to the inner node's value-provider
1227 /// builder.
1228 pub fn with_value_provider(mut self, option: &str, provider: ValueProvider) -> Self {
1229 self.inner = self.inner.with_value_provider(option, provider);
1230 self
1231 }
1232
1233 /// Forward through to the inner node's dynamic-options
1234 /// builder.
1235 pub fn with_dynamic_options(mut self, provider: DynamicOptionsProvider) -> Self {
1236 self.inner = self.inner.with_dynamic_options(provider);
1237 self
1238 }
1239}
1240
1241impl StrictNode<true, true> {
1242 /// Unwrap a fully-qualified strict node into a plain
1243 /// [`Node`]. The compile-time guarantee carries through to
1244 /// the moment of unwrapping: only nodes that have set both
1245 /// category and level can be downgraded.
1246 pub fn into_node(self) -> Node { self.inner }
1247}
1248
1249/// The top-level command tree for an application.
1250#[derive(Clone)]
1251pub struct CommandTree {
1252 /// Application name (used for env var naming).
1253 pub app_name: String,
1254 /// Root node (always a group).
1255 pub root: Node,
1256 /// Commands that exist but are hidden from root-level listing.
1257 pub hidden: std::collections::HashSet<String>,
1258 /// Help text for global flags. Falls back to per-node
1259 /// `flag_help` when the cursor sits inside a leaf that doesn't
1260 /// override the help line for a global flag like `--dataset` or
1261 /// `--profile`. Used by the value-position rapid-tap UX.
1262 pub global_flag_help: BTreeMap<String, String>,
1263 /// Extended help text for global flags. Same fallback chain as
1264 /// [`Self::global_flag_help`]; surfaced on triple-tap at a
1265 /// value position.
1266 pub global_flag_long_help: BTreeMap<String, String>,
1267 /// When true, every registered command must declare both a
1268 /// category (via [`Node::with_category`]) and an explicit
1269 /// level (via [`Node::with_level`]). [`Self::command`] /
1270 /// [`Self::group`] / [`Self::hidden_command`] panic on
1271 /// registration of an undertagged node, surfacing the
1272 /// problem at the call site rather than producing a
1273 /// silently-uncategorized completion tree at runtime.
1274 /// Opt-in for apps that want their stratified completion
1275 /// UX enforced at compile-test time.
1276 pub strict_metadata: bool,
1277 /// Minimum command [`Stability`] offered during completion. Commands below
1278 /// this threshold are omitted from suggestions (but remain runnable).
1279 /// Defaults to [`Stability::Preview`], so `Experimental` commands are hidden
1280 /// until lowered. [`handle_complete_env`] adjusts it per-completion when the
1281 /// line begins with `---experimental` / `---preview` / `---stable`.
1282 pub min_stability: Stability,
1283}
1284
1285impl CommandTree {
1286 /// Maximum [`Node::level`] across all root-level children. Drives
1287 /// the rotation cycle in [`complete_rotating`]: a tap beyond
1288 /// `max_level()` wraps back to level 1.
1289 ///
1290 /// Returns at least 1 (since [`DEFAULT_LEVEL`] is 1) so callers
1291 /// can use the result directly as a modular cycle length.
1292 pub fn max_level(&self) -> u32 {
1293 let mut max = DEFAULT_LEVEL;
1294 for child in self.root.children.values() {
1295 if child.level() > max {
1296 max = child.level();
1297 }
1298 }
1299 max
1300 }
1301
1302 /// Create a new command tree with an empty root group.
1303 ///
1304 /// The `app_name` is used to construct the environment variable name
1305 /// for completion callbacks (e.g., `_MYAPP_COMPLETE=bash`).
1306 pub fn new(app_name: &str) -> Self {
1307 CommandTree {
1308 app_name: app_name.to_string(),
1309 root: Node::empty_group(),
1310 hidden: std::collections::HashSet::new(),
1311 global_flag_help: BTreeMap::new(),
1312 global_flag_long_help: BTreeMap::new(),
1313 strict_metadata: false,
1314 min_stability: Stability::Preview,
1315 }
1316 }
1317
1318 /// Lookup help text for a global flag. Returns `None` if no
1319 /// global help was registered for this flag.
1320 pub fn global_flag_help_for(&self, flag: &str) -> Option<&str> {
1321 self.global_flag_help.get(flag).map(|s| s.as_str())
1322 }
1323
1324 /// Register help text for a global flag. Builder-style.
1325 pub fn global_flag_help(mut self, flag: &str, help: &str) -> Self {
1326 self.global_flag_help.insert(flag.to_string(), help.to_string());
1327 self
1328 }
1329
1330 /// Lookup extended help for a global flag.
1331 pub fn global_flag_long_help_for(&self, flag: &str) -> Option<&str> {
1332 self.global_flag_long_help.get(flag).map(|s| s.as_str())
1333 }
1334
1335 /// Register extended help for a global flag — surfaced on
1336 /// rapid triple-tap at a value position. Composes with
1337 /// [`Self::global_flag_help`].
1338 pub fn global_flag_long_help(mut self, flag: &str, help: &str) -> Self {
1339 self.global_flag_long_help.insert(flag.to_string(), help.to_string());
1340 self
1341 }
1342
1343 /// Opt-in to strict-metadata mode. Every subsequent call
1344 /// to [`Self::command`] / [`Self::group`] /
1345 /// [`Self::hidden_command`] checks that the node has both
1346 /// a category and an explicit level — registration panics
1347 /// if either is missing, with a message naming the
1348 /// offending command.
1349 ///
1350 /// Use this in apps that have committed to a stratified
1351 /// completion model and want the build to break if a new
1352 /// command is added without categorizing it.
1353 pub fn require_metadata(mut self) -> Self {
1354 self.strict_metadata = true;
1355 self
1356 }
1357
1358 /// Walk every registered command and check for missing
1359 /// category / level metadata. Returns `Ok(())` when every
1360 /// node satisfies the contract; `Err(Vec<MetadataError>)`
1361 /// otherwise with one entry per offending command.
1362 ///
1363 /// Always available regardless of `strict_metadata` — apps
1364 /// that want validation as a one-shot post-build check
1365 /// (CI test, debug-assert, etc.) can call this directly
1366 /// without enabling the panic-at-registration mode.
1367 pub fn validate(&self) -> Result<(), Vec<MetadataError>> {
1368 let mut errors = Vec::new();
1369 for (name, node) in &self.root.children {
1370 if node.category().is_none() {
1371 errors.push(MetadataError::MissingCategory {
1372 command: name.clone(),
1373 });
1374 }
1375 if node.level_explicit().is_none() {
1376 errors.push(MetadataError::MissingLevel {
1377 command: name.clone(),
1378 });
1379 }
1380 }
1381 if errors.is_empty() { Ok(()) } else { Err(errors) }
1382 }
1383
1384 /// Internal: panic if `strict_metadata` is set and `node`
1385 /// is missing required metadata. Called from every
1386 /// `command`-style registration helper so the error fires
1387 /// at the source line that registered the bad node.
1388 fn check_strict(&self, name: &str, node: &Node) {
1389 if !self.strict_metadata { return; }
1390 if node.category().is_none() {
1391 panic!("veks-completion: app '{}' has require_metadata() set, \
1392 but command '{name}' was registered without \
1393 Node::with_category(...). Add a category tag.",
1394 self.app_name);
1395 }
1396 if node.level_explicit().is_none() {
1397 panic!("veks-completion: app '{}' has require_metadata() set, \
1398 but command '{name}' was registered without \
1399 Node::with_level(...). Pick a tap-tier level (1, 2, 3, ...).",
1400 self.app_name);
1401 }
1402 }
1403
1404 /// Add a top-level command (leaf or group) to the tree.
1405 ///
1406 /// This is a builder method — it consumes and returns `self` for chaining.
1407 pub fn command(mut self, name: &str, node: Node) -> Self {
1408 self.check_strict(name, &node);
1409 self.root = self.root.with_child(name, node);
1410 self
1411 }
1412
1413 /// Add a top-level command using the type-state-checked
1414 /// [`StrictNode`] API. The signature requires
1415 /// `StrictNode<true, true>`, so calling this with a node
1416 /// missing either `with_category(...)` or `with_level(...)`
1417 /// is a **compile-time** error — no runtime panic, no
1418 /// silent skip. Recommended entry point for apps that want
1419 /// the stratified completion model strictly enforced.
1420 pub fn strict_command(
1421 mut self,
1422 name: &str,
1423 node: StrictNode<true, true>,
1424 ) -> Self {
1425 self.root = self.root.with_child(name, node.into_node());
1426 self
1427 }
1428
1429 /// Type-state-checked alias for grouping. Same compile-time
1430 /// guarantee as [`Self::strict_command`].
1431 pub fn strict_group(self, name: &str, node: StrictNode<true, true>) -> Self {
1432 self.strict_command(name, node)
1433 }
1434
1435 /// Type-state-checked variant of [`Self::hidden_command`].
1436 pub fn strict_hidden_command(
1437 mut self,
1438 name: &str,
1439 node: StrictNode<true, true>,
1440 ) -> Self {
1441 self.hidden.insert(name.to_string());
1442 self.root = self.root.with_child(name, node.into_node());
1443 self
1444 }
1445
1446 /// Add a top-level group to the tree. Alias for [`command`](Self::command).
1447 pub fn group(self, name: &str, node: Node) -> Self {
1448 self.command(name, node)
1449 }
1450
1451 /// Add a command that is registered but hidden from root-level listing.
1452 ///
1453 /// Hidden commands are still completable if the user types the name
1454 /// prefix directly — they are just excluded from the initial empty-prefix
1455 /// candidate list. Useful for aliases and shorthands.
1456 pub fn hidden_command(mut self, name: &str, node: Node) -> Self {
1457 self.check_strict(name, &node);
1458 self.hidden.insert(name.to_string());
1459 self.command(name, node)
1460 }
1461
1462 /// Built-in option: enable `--help` everywhere. Walks the whole
1463 /// tree and adds `--help` (boolean) to every node that doesn't
1464 /// already declare it. Embedders use this to opt into uniform
1465 /// help support without writing per-node `with_boolean_flags(&[
1466 /// "--help"])` boilerplate. The same `--help` shows up in tab
1467 /// completion at every level and is recognised by [`parse_argv`]
1468 /// as a known flag.
1469 ///
1470 /// Pair with [`render_usage`] in your handler:
1471 ///
1472 /// ```ignore
1473 /// let parsed = parse_argv(&tree, &argv)?;
1474 /// if parsed.flags.contains_key("--help") {
1475 /// // walk parsed.path to find the node, then:
1476 /// println!("{}", render_usage(node, &parsed.path));
1477 /// return Ok(());
1478 /// }
1479 /// ```
1480 pub fn with_auto_help(mut self) -> Self {
1481 attach_auto_help(&mut self.root);
1482 self
1483 }
1484
1485 /// Built-in option: attach a [`crate::providers::metricsql_provider`]
1486 /// at the supplied subcommand path. The path is `["sub1",
1487 /// "sub2", …]` — the chain of children to descend through from
1488 /// the root before the provider takes over completion.
1489 ///
1490 /// Equivalent to manually navigating to the node and calling
1491 /// `with_subtree_provider(metricsql_provider(catalog))`, but
1492 /// surfaces the intent at tree-construction time.
1493 pub fn with_metricsql_at(
1494 mut self,
1495 path: &[&str],
1496 catalog: std::sync::Arc<dyn crate::providers::MetricsqlCatalog>,
1497 ) -> Self {
1498 if let Some(node) = walk_path_mut(&mut self.root, path) {
1499 *node = std::mem::take(node)
1500 .with_subtree_provider(crate::providers::metricsql_provider(catalog));
1501 }
1502 self
1503 }
1504}
1505
1506fn attach_auto_help(node: &mut Node) {
1507 if !node.flags.iter().any(|f| f == "--help") {
1508 node.flags.push("--help".to_string());
1509 node.boolean_flags.insert("--help".to_string());
1510 if !node.flag_help.contains_key("--help") {
1511 node.flag_help.insert("--help".to_string(),
1512 "Show usage information for this command.".to_string());
1513 }
1514 }
1515 for child in node.children.values_mut() {
1516 attach_auto_help(child);
1517 }
1518}
1519
1520fn walk_path_mut<'a>(root: &'a mut Node, path: &[&str]) -> Option<&'a mut Node> {
1521 let mut node = root;
1522 for segment in path {
1523 node = node.children.get_mut(*segment)?;
1524 }
1525 Some(node)
1526}
1527
1528/// Check if a word on the command line matches (and thus consumes) a
1529/// defined option. Handles exact flags, `key=value`, `--key=value`,
1530/// and cross-style equivalence.
1531fn word_matches_option(word: &str, option: &str) -> bool {
1532 if word == option { return true; }
1533
1534 if let Some(key) = option.strip_suffix('=') {
1535 if word.starts_with(key) && word[key.len()..].starts_with('=') {
1536 return true;
1537 }
1538 let dashed = format!("--{key}");
1539 if word.starts_with(&dashed) && word[dashed.len()..].starts_with('=') {
1540 return true;
1541 }
1542 }
1543
1544 if option.starts_with("--") && !option.ends_with('=') {
1545 if word.starts_with(option) && word[option.len()..].starts_with('=') {
1546 return true;
1547 }
1548 let bare = &option[2..];
1549 if word.starts_with(bare) && word[bare.len()..].starts_with('=') {
1550 return true;
1551 }
1552 }
1553
1554 false
1555}
1556
1557/// Collect canonical keys for options already present on the command line.
1558fn consumed_keys(words: &[&str], options: &[String]) -> std::collections::HashSet<String> {
1559 let mut consumed = std::collections::HashSet::new();
1560 for &word in words {
1561 for opt in options {
1562 if word_matches_option(word, opt) {
1563 let key = opt.trim_start_matches('-').trim_end_matches('=');
1564 consumed.insert(key.to_string());
1565 }
1566 }
1567 }
1568 consumed
1569}
1570
1571/// Check if an option's canonical key is in the consumed set.
1572fn is_consumed(option: &str, consumed: &std::collections::HashSet<String>) -> bool {
1573 let key = option.trim_start_matches('-').trim_end_matches('=');
1574 consumed.contains(key)
1575}
1576
1577/// True when any flag that `flag` conflicts with is already on the
1578/// line — `flag` is then withheld from completion. Conflicting flags
1579/// match in any spelling `word_matches_option` understands plus
1580/// registered short aliases.
1581fn conflict_on_line(node: &Node, flag: &str, remaining: &[&str]) -> bool {
1582 let Some(conflicts) = node.flag_conflicts.get(flag) else {
1583 return false;
1584 };
1585 conflicts.iter().any(|c| {
1586 remaining.iter().any(|w| {
1587 word_matches_option(w, c) || node.resolve_flag_token(w) == Some(c.as_str())
1588 })
1589 })
1590}
1591
1592/// Compute completion candidates for the given input words.
1593///
1594/// Options already present on the command line are excluded. Both
1595/// `--flag` and bare `key=` styles are supported and deduplicated.
1596/// Dynamic options from context providers are included.
1597///
1598/// Always operates at tap level 1. For stratified completion
1599/// where successive tabs reveal more candidates, use
1600/// [`complete_at_tap`].
1601pub fn complete(tree: &CommandTree, words: &[&str]) -> Vec<String> {
1602 complete_at_tap(tree, words, 1)
1603}
1604
1605/// Rotating-tier completion. Returns root-level candidates whose
1606/// `Node::level() == only_level` (NOT the cumulative `<=` set), so
1607/// successive tab taps cycle through tiers one at a time and wrap
1608/// around after the highest. Once the user starts typing a
1609/// specific name, behaves identically to [`complete`] — the level
1610/// filter applies only at the root with an empty partial.
1611///
1612/// Use [`complete_rotating`] (or [`handle_complete_env`] which
1613/// already wires this up) for the recommended UX:
1614///
1615/// tap 1 → only level 1 (Primary)
1616/// tap 2 → only level 2 (Secondary)
1617/// tap 3 → only level 3 (Advanced)
1618/// tap 4 → wraps back to level 1
1619/// ...
1620///
1621/// Cycle length is the tree's [`max_level`].
1622pub fn complete_at_level_only(tree: &CommandTree, words: &[&str], only_level: u32) -> Vec<String> {
1623 // Words shape: [binary, completed..., partial]. At absolute root
1624 // with no input at all, words may have just [binary], so treat
1625 // missing partial as empty.
1626 let partial = if words.len() > 1 { *words.last().unwrap_or(&"") } else { "" };
1627 let completed: &[&str] = if words.len() > 1 { &words[1..words.len() - 1] } else { &[] };
1628
1629 // Rotation only filters when the user is at a group prompt with
1630 // no partial typed. Once they start typing a name, we want
1631 // anything matching it (regardless of tier) so half-typed
1632 // higher-tier commands still complete on the first tap.
1633 if !partial.is_empty() {
1634 return complete(tree, words);
1635 }
1636
1637 // Walk the tree following completed words to find the current
1638 // group node. If we hit a non-existent child or a leaf, fall
1639 // back to the standard completion engine.
1640 let mut node = &tree.root;
1641 let at_root = completed.is_empty();
1642 for &word in completed {
1643 match node.child(word) {
1644 Some(child) => node = child,
1645 None => return complete(tree, words),
1646 }
1647 }
1648
1649 // Apply the rotation filter to whichever group we landed on.
1650 // Cumulative semantics: tap N reveals every child whose level is
1651 // <= N. So a single tap shows layer 1; a rapid double-tap shows
1652 // layers 1 + 2 together; etc. The result is always sorted in
1653 // *layer order* (layer 1 candidates first, then layer 2, …) with
1654 // the standard `--`-flags-last + alphabetical ordering applied
1655 // within each layer.
1656 if !node.children.is_empty() {
1657 let mut candidates: Vec<(u32, String)> = node.children.iter()
1658 .filter(|(k, _)| !at_root || !tree.hidden.contains(k.as_str()))
1659 .filter(|(_, child)| child.stability() >= tree.min_stability)
1660 .filter(|(_, child)| child.level() <= only_level)
1661 .map(|(k, child)| (child.level(), k.to_string()))
1662 .collect();
1663 candidates.sort_by(|(la, a), (lb, b)| {
1664 la.cmp(lb)
1665 .then_with(|| a.starts_with('-').cmp(&b.starts_with('-')))
1666 .then_with(|| a.cmp(b))
1667 });
1668 return candidates.into_iter().map(|(_, k)| k).collect();
1669 }
1670
1671 // Landed on a leaf — no children to rotate, defer to standard
1672 // completion (which handles option/value completion).
1673 complete(tree, words)
1674}
1675
1676/// Convenience wrapper over [`complete_at_level_only`] that maps
1677/// the raw tap counter to the rotating level. Cycle length is
1678/// computed relative to whichever group node the user has
1679/// descended into — a subgroup with only level-1 children has a
1680/// cycle length of 1 (every tap shows the same set), while a
1681/// subgroup with Primary+Secondary+Advanced children has a cycle
1682/// length of 3. A tap beyond the cycle wraps back to level 1.
1683pub fn complete_rotating(tree: &CommandTree, words: &[&str], tap_count: u32) -> Vec<String> {
1684 complete_rotating_with_raw(tree, words, tap_count, "", 0)
1685}
1686
1687/// Same as [`complete_rotating`] but additionally threads the raw
1688/// `COMP_LINE` and cursor offset through to subtree providers via
1689/// [`PartialParse::raw_line`] / [`PartialParse::cursor_offset`].
1690/// Used by [`handle_complete_env`] so grammar-aware providers can
1691/// inspect raw text + cursor position.
1692pub fn complete_rotating_with_raw(
1693 tree: &CommandTree,
1694 words: &[&str],
1695 tap_count: u32,
1696 raw_line: &str,
1697 cursor_offset: usize,
1698) -> Vec<String> {
1699 let completed: &[&str] = if words.len() > 1 { &words[1..words.len() - 1] } else { &[] };
1700 let partial: &str = if words.len() > 1 { words.last().unwrap_or(&"") } else { "" };
1701 let mut node = &tree.root;
1702 for &word in completed {
1703 match node.child(word) {
1704 Some(child) => node = child,
1705 None => break,
1706 }
1707 }
1708
1709 // If any node on the resolved path has a subtree provider, the
1710 // rotating-tier filter doesn't apply — the provider owns the
1711 // candidate output. Pass the FULL tap_count through (not the
1712 // modulo-by-max-children version) so the provider can layer
1713 // its own output by tap (e.g., metricsql shows metric names
1714 // at tap 1 and adds inner functions at tap 2).
1715 if path_has_subtree_provider(tree, completed) {
1716 return complete_at_tap_with_raw(tree, words, tap_count, raw_line, cursor_offset);
1717 }
1718
1719 let max = max_level_of_children(node).max(1);
1720 let only = ((tap_count.saturating_sub(1)) % max) + 1;
1721 // The modulo-by-max-children transform is for cycling through
1722 // command-level visibility tiers. At a *value* position (the
1723 // previous word is a value-taking flag) there are no command
1724 // tiers to cycle, so the raw `tap_count` should flow through
1725 // unmodified — otherwise a leaf with no children clamps
1726 // `tap_count` to 1 forever and the rapid-double-tap help line
1727 // (gated on `tap_count == 2` inside
1728 // [`complete_at_tap_with_raw`]) never fires.
1729 let prev_word_opt = completed.last().copied();
1730 let at_value_position = prev_word_opt
1731 .and_then(|w| node.resolve_flag_token(w))
1732 .map(|w| {
1733 !node.boolean_flags.contains(w)
1734 && (node.value_providers.contains_key(w)
1735 || node.flag_help_for(w).is_some())
1736 })
1737 .unwrap_or(false);
1738 let effective_tap = if at_value_position { tap_count } else { only };
1739 // Empty partial at a group boundary → apply the level filter
1740 // via complete_at_level_only. Otherwise dispatch through
1741 // complete_at_tap_with_raw so the engine sees raw context.
1742 if partial.is_empty() && !at_value_position {
1743 return complete_at_level_only(tree, words, effective_tap);
1744 }
1745 complete_at_tap_with_raw(tree, words, effective_tap, raw_line, cursor_offset)
1746}
1747
1748/// Maximum [`Node::level`] across the immediate children of `node`,
1749/// or [`DEFAULT_LEVEL`] if `node` is a leaf or has no children.
1750pub(crate) fn max_level_of_children(node: &Node) -> u32 {
1751 let mut max = DEFAULT_LEVEL;
1752 for child in node.children.values() {
1753 if child.level() > max {
1754 max = child.level();
1755 }
1756 }
1757 max
1758}
1759
1760/// Stratified completion: returns root-level candidates with
1761/// `Node::level() <= tap_count`, so the Nth tab tap reveals
1762/// progressively more commands. Inside a subcommand or with a
1763/// non-empty partial, behaves identically to [`complete`] —
1764/// the level filter applies only when the user is at the
1765/// root prompt with no prefix typed.
1766///
1767/// Default Node level is [`DEFAULT_LEVEL`] (= 1), so apps that
1768/// haven't categorized their commands see the same single-tap
1769/// behavior they did before stratification.
1770pub fn complete_at_tap(tree: &CommandTree, words: &[&str], tap_count: u32) -> Vec<String> {
1771 complete_at_tap_with_raw(tree, words, tap_count, "", 0)
1772}
1773
1774/// Same as [`complete_at_tap`] but additionally accepts the raw
1775/// `COMP_LINE` and the cursor's byte offset. Subtree providers
1776/// receive these in [`PartialParse::raw_line`] /
1777/// [`PartialParse::cursor_offset`], enabling grammar-aware
1778/// completion (e.g. for embedded query DSLs like MetricsQL or
1779/// PromQL where bracket / quote / operator state at the cursor
1780/// matters).
1781///
1782/// Pass empty `raw_line` and `0` for `cursor_offset` if the caller
1783/// doesn't have raw context — grammar helpers in [`PartialParse`]
1784/// fall back to the tokenised view in that case.
1785pub fn complete_at_tap_with_raw(
1786 tree: &CommandTree,
1787 words: &[&str],
1788 tap_count: u32,
1789 raw_line: &str,
1790 cursor_offset: usize,
1791) -> Vec<String> {
1792 if words.len() <= 1 {
1793 let mut cmds: Vec<String> = tree.root.child_names().iter()
1794 .filter(|s| !tree.hidden.contains(**s))
1795 .filter(|s| {
1796 tree.root.child(s)
1797 .map(|n| n.stability() >= tree.min_stability)
1798 .unwrap_or(true)
1799 })
1800 .filter(|s| {
1801 tree.root.child(s)
1802 .map(|n| n.level() <= tap_count)
1803 .unwrap_or(true)
1804 })
1805 .map(|s| s.to_string())
1806 .collect();
1807 cmds.sort_by(|a, b| {
1808 a.starts_with('-').cmp(&b.starts_with('-')).then_with(|| a.cmp(b))
1809 });
1810 return cmds;
1811 }
1812
1813 let partial = words.last().unwrap_or(&"");
1814 let completed = &words[1..words.len() - 1];
1815 let at_root = completed.is_empty();
1816
1817 // Walk the tree following completed words. Track the deepest node
1818 // with a subtree_provider attached — that provider takes
1819 // precedence over the regular completion path (TODO item 7),
1820 // letting embedders register context-aware completions inside any
1821 // subtree without a pre-walker hook.
1822 let mut node = &tree.root;
1823 let mut remaining_start = 0;
1824 let mut tree_path: Vec<&str> = Vec::new();
1825 let mut deepest_subtree: Option<&SubtreeProvider> = node.subtree_provider();
1826 for (i, &word) in completed.iter().enumerate() {
1827 match node.child(word) {
1828 Some(child) => {
1829 node = child;
1830 remaining_start = i + 1;
1831 tree_path.push(word);
1832 if let Some(p) = node.subtree_provider() {
1833 deepest_subtree = Some(p);
1834 }
1835 }
1836 None => break,
1837 }
1838 }
1839 let remaining = &completed[remaining_start..];
1840
1841 if let Some(provider) = deepest_subtree {
1842 let pp = PartialParse {
1843 completed: completed.to_vec(),
1844 partial,
1845 tree_path,
1846 raw_line,
1847 cursor_offset,
1848 tap_count,
1849 };
1850 return provider(&pp);
1851 }
1852
1853 // Unified node — a node may carry children, flags, or both.
1854 // Order of candidate sourcing:
1855 // 1. If the previous word is a value-taking flag, defer
1856 // entirely to its value provider.
1857 // 2. If the partial is `key=…`, defer to the key's provider.
1858 // 3. Otherwise, collect children (subject to level filter at
1859 // root with empty partial) + flags (static + dynamic) +
1860 // global flag tokens, prefix-filtered.
1861
1862 // (1) Value-completion for the previous flag. Short aliases
1863 // (`-c`) resolve to their long form first — value completion
1864 // must work identically for `attach --config <TAB>` and
1865 // `attach -c <TAB>`.
1866 if let Some(&prev_raw) = remaining.last()
1867 && let Some(prev_word) = node.resolve_flag_token(prev_raw)
1868 && !node.boolean_flags.contains(prev_word)
1869 {
1870 // Rapid double-tab at a value position prints the flag's
1871 // help text to stderr above the candidate list, so the user
1872 // can see what the option actually means without abandoning
1873 // the completion. Stdout still carries the candidates so
1874 // bash's COMPREPLY is unaffected. Gate to exactly tap 2 so
1875 // a hold-the-tab-key burst doesn't stack the help line
1876 // over and over.
1877 emit_value_position_help(tree, node, prev_word, tap_count);
1878 if let Some(provider) = node.value_providers.get(prev_word) {
1879 return provider(partial, remaining);
1880 }
1881 return Vec::new();
1882 }
1883
1884 // (2) `key=value_prefix` form. Bash's default COMP_WORDBREAKS
1885 // contains `=`, so readline already treats the current word as
1886 // just the post-`=` segment. We must return BARE values to
1887 // avoid `key=key=value` stutter.
1888 if let Some(eq_pos) = partial.find('=') {
1889 let key = &partial[..eq_pos];
1890 let value_partial = &partial[eq_pos + 1..];
1891 let key_eq = format!("{key}=");
1892 let dashed_key = format!("--{key}");
1893 if let Some(provider) = node.value_providers.get(&key_eq)
1894 .or_else(|| node.value_providers.get(&dashed_key))
1895 {
1896 return provider(value_partial, remaining);
1897 }
1898 return Vec::new();
1899 }
1900
1901 // (3) Children (subcommands).
1902 let mut child_candidates: Vec<(u32, String)> = node.children.iter()
1903 .filter(|(k, _)| k.starts_with(partial))
1904 .filter(|(k, _)| !at_root || !partial.is_empty() || !tree.hidden.contains(k.as_str()))
1905 .filter(|(_, child)| child.stability() >= tree.min_stability)
1906 .filter(|(_, child)| {
1907 // Level filter only applies at root with an empty
1908 // partial — once the user starts typing a name,
1909 // return matching commands regardless of tap tier.
1910 !at_root || !partial.is_empty() || child.level() <= tap_count
1911 })
1912 .map(|(k, child)| (child.level(), k.to_string()))
1913 .collect();
1914 child_candidates.sort_by(|(la, a), (lb, b)| {
1915 la.cmp(lb)
1916 .then_with(|| a.starts_with('-').cmp(&b.starts_with('-')))
1917 .then_with(|| a.cmp(b))
1918 });
1919
1920 // (3) Flags on this node — static + dynamic.
1921 let mut flag_candidates: Vec<String> = Vec::new();
1922 if !node.flags.is_empty() || node.dynamic_options.is_some() {
1923 let mut all_flags: Vec<String> = node.flags.clone();
1924 if let Some(provider) = node.dynamic_options {
1925 for opt in provider(partial, remaining) {
1926 if !all_flags.contains(&opt) {
1927 all_flags.push(opt);
1928 }
1929 }
1930 }
1931 let consumed = consumed_keys(remaining, &all_flags);
1932 for f in &all_flags {
1933 if f.starts_with(partial)
1934 && !is_consumed(f, &consumed)
1935 && !conflict_on_line(node, f, remaining)
1936 {
1937 flag_candidates.push(f.clone());
1938 }
1939 }
1940 }
1941
1942 flag_candidates.sort_by(|a, b| {
1943 a.starts_with('-').cmp(&b.starts_with('-')).then_with(|| a.cmp(b))
1944 });
1945
1946 // (4) Positional value completion: when this command takes
1947 // positionals, fewer than its declared slot count have been
1948 // entered, and the cursor is on a bare word (not after a flag),
1949 // offer the provider's dynamic candidates. The provider sees the
1950 // completed words and decides what the slot under the cursor
1951 // means (e.g. `config set <key> <value>` completes keys at slot
1952 // 0 and key-specific values at slot 1).
1953 let mut positional_candidates: Vec<String> = Vec::new();
1954 if let Some(provider) = node.positional_provider()
1955 && !partial.starts_with('-')
1956 && positionals_entered(remaining, &node.flags, &node.boolean_flags)
1957 < node.positional_slots.max(1)
1958 {
1959 positional_candidates = provider(partial, remaining);
1960 }
1961
1962 // Children, then positional values, then flags.
1963 let mut out: Vec<String> = child_candidates.into_iter().map(|(_, k)| k).collect();
1964 out.extend(positional_candidates);
1965 out.extend(flag_candidates);
1966 out
1967}
1968
1969/// Count the bare positional words already entered in `remaining`, skipping
1970/// flags and the values consumed by value-taking flags this node knows about.
1971/// (Global value-flags the node doesn't list may be miscounted; that only
1972/// suppresses a suggestion, never misfires one.)
1973fn positionals_entered(
1974 remaining: &[&str],
1975 flags: &[String],
1976 boolean_flags: &std::collections::HashSet<String>,
1977) -> usize {
1978 let mut count = 0;
1979 let mut i = 0;
1980 while i < remaining.len() {
1981 let w = remaining[i];
1982 if w.starts_with("--") {
1983 let takes_value =
1984 !w.contains('=') && flags.iter().any(|f| f == w) && !boolean_flags.contains(w);
1985 if takes_value {
1986 i += 1; // skip the flag's value
1987 }
1988 } else if w.starts_with('-') && w.len() > 1 {
1989 // short flag(s) — not treated as a positional
1990 } else {
1991 count += 1;
1992 }
1993 i += 1;
1994 }
1995 count
1996}
1997
1998/// True when any node on the path resolved by `completed` (including
1999/// the root itself) carries a [`SubtreeProvider`]. Resolution stops at
2000/// the first word that isn't a known child — same walk the completion
2001/// engine performs.
2002pub(crate) fn path_has_subtree_provider(tree: &CommandTree, completed: &[&str]) -> bool {
2003 let mut node = &tree.root;
2004 if node.subtree_provider().is_some() {
2005 return true;
2006 }
2007 for &word in completed {
2008 match node.child(word) {
2009 Some(child) => {
2010 node = child;
2011 if node.subtree_provider().is_some() {
2012 return true;
2013 }
2014 }
2015 None => break,
2016 }
2017 }
2018 false
2019}
2020
2021/// Convert semantic candidates into the exact bytes handed to bash.
2022///
2023/// The bash hook registers with a global `-o nospace` (see
2024/// [`print_bash_script`]), so readline never appends a space — any
2025/// trailing space must travel inside the candidate string itself.
2026/// This is the engine's per-candidate spacing pass, applied at the
2027/// emission boundary ([`handle_complete_env`] and the
2028/// `---trace-completion` diagnostic) so the semantic [`complete`]
2029/// family keeps returning bare words:
2030///
2031/// - Tree-walk candidates (subcommands, flags, value/positional
2032/// provider output) are complete words — `TERMINAL` in the intent
2033/// model of sysref §11.13 — and get a trailing space appended, so
2034/// accepting `list` yields `list ` with the cursor ready for the
2035/// next word.
2036/// - Candidates ending in `=` (`cycles=`, `--mode=`) await their
2037/// value and stay open — the cursor stays glued.
2038/// - Candidates ending in `/` are path prefixes still being built
2039/// and stay open.
2040/// - When a [`SubtreeProvider`] sits anywhere on the resolved path,
2041/// the whole candidate set is grammar-owned (`delta(`, `up{job=`,
2042/// `[5m`) and passes through verbatim — the provider alone decides
2043/// its spacing.
2044///
2045/// `words` has the same shape the completion engine takes: index 0
2046/// is the binary name, the last entry is the (possibly empty) word
2047/// under the cursor.
2048pub fn shell_ready_candidates(
2049 tree: &CommandTree,
2050 words: &[&str],
2051 candidates: Vec<String>,
2052) -> Vec<String> {
2053 let completed: &[&str] = if words.len() > 1 { &words[1..words.len() - 1] } else { &[] };
2054 if path_has_subtree_provider(tree, completed) {
2055 return candidates;
2056 }
2057 candidates
2058 .into_iter()
2059 .map(|c| {
2060 if c.is_empty() || c.ends_with([' ', '=', '/']) {
2061 c
2062 } else {
2063 format!("{c} ")
2064 }
2065 })
2066 .collect()
2067}
2068
2069/// Supported shells for completion-script generation.
2070///
2071/// `Bash` and `Zsh` (via bash-compatible mode) are fully implemented;
2072/// `Fish`, `Elvish`, and `PowerShell` placeholders are accepted but
2073/// emit a stub-with-warning so callers can register them in a CLI
2074/// `--shell` flag without separate dispatch.
2075#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2076pub enum Shell {
2077 Bash,
2078 Zsh,
2079 Fish,
2080 Elvish,
2081 PowerShell,
2082}
2083
2084impl Shell {
2085 /// Parse a shell name (case-insensitive) — `"bash"`, `"zsh"`,
2086 /// `"fish"`, `"elvish"`, or `"pwsh"` / `"powershell"`. Returns
2087 /// `None` for unrecognized names.
2088 pub fn from_name(name: &str) -> Option<Self> {
2089 match name {
2090 "bash" => Some(Self::Bash),
2091 "zsh" => Some(Self::Zsh),
2092 "fish" => Some(Self::Fish),
2093 "elvish" => Some(Self::Elvish),
2094 "pwsh" | "powershell" => Some(Self::PowerShell),
2095 _ => None,
2096 }
2097 }
2098
2099 pub fn name(self) -> &'static str {
2100 match self {
2101 Self::Bash => "bash",
2102 Self::Zsh => "zsh",
2103 Self::Fish => "fish",
2104 Self::Elvish => "elvish",
2105 Self::PowerShell => "powershell",
2106 }
2107 }
2108}
2109
2110/// Detect the user's interactive shell.
2111///
2112/// Tries `$SHELL` first (the standard Unix mechanism), then falls
2113/// back to inspecting the parent process's `/proc/PID/comm` on Linux
2114/// for the case where `$SHELL` is set to something other than the
2115/// actual interactive shell (e.g., when running under a wrapper).
2116/// Returns `None` if no recognized shell can be determined.
2117pub fn detect_shell() -> Option<Shell> {
2118 if let Ok(shell_path) = std::env::var("SHELL") {
2119 let name = std::path::Path::new(&shell_path)
2120 .file_name()
2121 .and_then(|n| n.to_str())
2122 .unwrap_or("");
2123 if let Some(s) = Shell::from_name(name) {
2124 return Some(s);
2125 }
2126 }
2127 #[cfg(target_os = "linux")]
2128 {
2129 // Read PPid from /proc/self/status — avoids a libc dep on
2130 // getppid(2). Format line: `PPid:\t<pid>\n`.
2131 if let Ok(status) = std::fs::read_to_string("/proc/self/status")
2132 && let Some(ppid_line) = status.lines().find(|l| l.starts_with("PPid:"))
2133 && let Some(ppid) = ppid_line.split_whitespace().nth(1)
2134 && let Ok(comm) = std::fs::read_to_string(format!("/proc/{}/comm", ppid)) {
2135 let name = comm.trim();
2136 if let Some(s) = Shell::from_name(name) {
2137 return Some(s);
2138 }
2139 }
2140 }
2141 None
2142}
2143
2144/// Print a completions snippet for `<app> completions` (no `--shell`)
2145/// — the convenience entry point users typically call once at shell
2146/// startup. Auto-detects the user's shell and emits a comment header
2147/// plus an indirect-`source <(...)` line that pulls the actual script
2148/// from `<app> completions --shell <shell>`.
2149///
2150/// The indirect form is what makes `eval "$(myapp completions)"`
2151/// safe regardless of which shell the user is in: the heredoc content
2152/// (which contains backslashes, `$`, etc.) is sourced from a
2153/// subshell rather than substituted into the caller's `eval` argument.
2154///
2155/// Falls back to a help message if shell detection fails.
2156pub fn print_indirect_wrapper(app_name: &str) {
2157 // Use argv[0] exactly as the user invoked us. If they typed
2158 // `veks completions`, emit `source <(veks completions ...)`.
2159 // If they typed `./target/release/veks completions`, emit that
2160 // path. The point: the snippet they paste into ~/.bashrc should
2161 // re-invoke the binary the *same way* they just did, not via a
2162 // canonicalised absolute path that may not exist on a different
2163 // machine, in a different toolchain, or after a `cargo install`.
2164 // We deliberately do NOT call `std::env::current_exe()` here —
2165 // that resolves symlinks and ignores how the user actually ran
2166 // the binary.
2167 let app_path = std::env::args_os()
2168 .next()
2169 .map(|p| p.to_string_lossy().into_owned())
2170 .unwrap_or_else(|| app_name.to_string());
2171
2172 match detect_shell() {
2173 Some(Shell::Bash) => {
2174 println!("# {} tab-completion for bash", app_name);
2175 println!("# To activate: eval \"$({} completions)\"", app_name);
2176 println!("# To persist: echo 'eval \"$({} completions)\"' >> ~/.bashrc", app_name);
2177 println!("source <(\"{}\" completions --shell bash)", app_path);
2178 }
2179 Some(Shell::Zsh) => {
2180 println!("# {} tab-completion for zsh", app_name);
2181 println!("# To activate: eval \"$({} completions)\"", app_name);
2182 println!("# To persist: echo 'eval \"$({} completions)\"' >> ~/.zshrc", app_name);
2183 println!("source <(\"{}\" completions --shell zsh)", app_path);
2184 }
2185 Some(Shell::Fish) => {
2186 println!("# {} tab-completion for fish", app_name);
2187 println!("# To activate: eval ({} completions)", app_name);
2188 println!("# To persist: add to ~/.config/fish/config.fish");
2189 println!("\"{}\" completions --shell fish | source", app_path);
2190 }
2191 Some(other) => {
2192 // Recognized shell but no auto-wrapper format defined;
2193 // emit the direct script.
2194 print_completions(app_name, other);
2195 }
2196 None => {
2197 println!("# {0}: could not detect your shell.", app_name);
2198 println!("# Use: eval \"$({0} completions --shell bash)\"", app_name);
2199 }
2200 }
2201}
2202
2203/// Print a direct completion script for `<app> completions --shell
2204/// <shell>`. This is what the indirect wrapper sources at shell
2205/// startup; callers can invoke it directly when they want the raw
2206/// script in stdout.
2207///
2208/// `Bash` is fully implemented. `Zsh` reuses the bash script via
2209/// bash-compatible mode (with a stderr note). `Fish`, `Elvish`, and
2210/// `PowerShell` print a stderr stub indicating they're not yet
2211/// implemented but accept the shell choice without panicking.
2212pub fn print_completions(app_name: &str, shell: Shell) {
2213 match shell {
2214 Shell::Bash => print_bash_script(app_name),
2215 Shell::Zsh => {
2216 eprintln!("# zsh completions: using bash-compatible mode");
2217 print_bash_script(app_name);
2218 }
2219 Shell::Fish | Shell::Elvish | Shell::PowerShell => {
2220 eprintln!(
2221 "# {} completions for `{}` are not yet implemented",
2222 shell.name(), app_name,
2223 );
2224 }
2225 }
2226}
2227
2228/// Generate a bash completion script that calls back into the app.
2229///
2230/// The emitted script is intentionally minimal — a single
2231/// `complete -F` registration plus a body that hands the raw
2232/// `$COMP_LINE` and `$COMP_POINT` to the binary. All
2233/// word-splitting and candidate logic runs in Rust inside
2234/// [`handle_complete_env`]; the bash side never sees the
2235/// completion rules. That keeps user-facing behavior from
2236/// drifting between shell and binary on upgrades — the only
2237/// thing that can break is the (trivial) handoff itself.
2238pub fn print_bash_script(app_name: &str) {
2239 let env_var = format!("_{}_COMPLETE", app_name.to_uppercase().replace('-', "_"));
2240 // Sourcing this script marks completions as registered in the shell. The
2241 // marker is exported so the binary itself can detect it (and stop nudging
2242 // the user to enable completions). See [`completions_registered_marker`].
2243 let marker = completions_registered_marker(app_name);
2244
2245 // Echo argv[0] verbatim — whatever the user typed when invoking
2246 // the binary (`veks`, `./target/release/veks`,
2247 // `/usr/local/bin/veks`, etc.). Resist the temptation to
2248 // canonicalise via `current_dir().join(...)` or
2249 // `std::env::current_exe()` — both produce paths that survive
2250 // `cargo install`/symlinks/PATH-rebinding worse than the bare
2251 // argv[0] does, and both surprise users who explicitly chose to
2252 // call the binary by short name.
2253 let completer = std::env::args_os()
2254 .next()
2255 .map(|p| p.to_string_lossy().into_owned())
2256 .unwrap_or_else(|| app_name.to_string());
2257
2258 // Hook contract: force bash into "raw mode" so its built-in
2259 // shell-quoting heuristics don't fight grammar-aware splicing.
2260 //
2261 // `local IFS=$'\n'`
2262 // Candidates may contain spaces (quoted label values,
2263 // grouped multi-token completions). Newline-only IFS
2264 // keeps `($(…))` from re-splitting them.
2265 //
2266 // `local COMP_WORDBREAKS=$' \t\n<>;|&'`
2267 // Bash's default WORDBREAKS includes `' " = ( :`, which
2268 // makes readline (a) split mid-grammar-token and (b)
2269 // auto-close unmatched quotes when the candidate ends in
2270 // an open context (e.g. `delta(` inside `'…'`). Stripping
2271 // those characters tells readline "the engine owns these
2272 // contexts; don't touch them". Set locally so the user's
2273 // interactive `COMP_WORDBREAKS` is untouched outside the
2274 // call. Must match
2275 // [`PartialParse::DEFAULT_BASH_WORDBREAKS`] exactly so
2276 // `shell_word_start` reasons about the same boundaries.
2277 //
2278 // `-o nosort`
2279 // Engine emits layer-ordered output (rapid-tap tier
2280 // ordering) — readline must not re-sort alphabetically.
2281 //
2282 // `-o nospace`
2283 // The ENGINE owns per-candidate spacing, not readline.
2284 // Grammar candidates (`delta(`, `up{`, `[5m`) are
2285 // mid-context inserts — a shell-appended space would put
2286 // the cursor outside the context the user is building.
2287 // Word-complete candidates (subcommands, flags, finished
2288 // values) instead carry their trailing space inside the
2289 // candidate bytes (see [`shell_ready_candidates`]), which
2290 // `IFS=$'\n'` preserves through the array expansion.
2291 // Global `-o nospace` + engine-side spaces is the only
2292 // way bash allows per-candidate control.
2293 //
2294 // Things deliberately NOT in the script:
2295 // - `_COMP_SHELL_PID=$$`: engine reads it via `getppid()`.
2296 // - `2>/dev/null`: binary is silent in completion mode;
2297 // diagnostics live on `---trace-completion`.
2298 print!(r#"export {marker}=1
2299_{app}_complete() {{ local IFS=$'\n'; local COMP_WORDBREAKS=$' \t\n<>;|&'; COMPREPLY=($({env_var}=bash "{completer}" "$COMP_LINE" "$COMP_POINT")); }}
2300complete -o nosort -o nospace -F _{app}_complete {app}
2301"#,
2302 app = app_name,
2303 marker = marker,
2304 env_var = env_var,
2305 completer = completer,
2306 );
2307}
2308
2309/// The environment variable the completion-registration script exports to mark
2310/// that completions are wired up in the current shell — e.g.
2311/// `_VECTORDATA_COMPLETIONS_REGISTERED`. The binary checks it (see
2312/// [`hint_completions_unregistered`]) to decide whether to nudge the user.
2313pub fn completions_registered_marker(app_name: &str) -> String {
2314 format!("_{}_COMPLETIONS_REGISTERED", app_name.to_uppercase().replace('-', "_"))
2315}
2316
2317/// Print a one-line nudge to **stderr** when tab-completion for `app_name` is
2318/// not yet enabled in this shell, telling the user how to turn it on
2319/// permanently. Call once per normal (non-completion) invocation.
2320///
2321/// No-op when:
2322/// - the registration marker is already exported (completions are active), or
2323/// - stderr is not a terminal (don't spam pipes, scripts, cron, or daemons), or
2324/// - the user is in the middle of running `<app> completions` (setting it up).
2325pub fn hint_completions_unregistered(app_name: &str) {
2326 use std::io::IsTerminal;
2327
2328 if std::env::var_os(completions_registered_marker(app_name)).is_some() {
2329 return;
2330 }
2331 if !std::io::stderr().is_terminal() {
2332 return;
2333 }
2334 if std::env::args().skip(1).any(|a| a == "completions") {
2335 return;
2336 }
2337
2338 eprintln!(
2339 "note: tab-completion for `{app}` is not enabled in this shell.\n \
2340 enable now: eval \"$({app} completions)\"\n \
2341 enable permanently: echo 'eval \"$({app} completions)\"' >> ~/.bashrc # or ~/.zshrc",
2342 app = app_name,
2343 );
2344}
2345
2346/// Tokenize a shell line up to `point`, returning the prior
2347/// completed words and the current (in-progress) token. Honors
2348/// single and double quotes and `\` escapes; preserves `=` as
2349/// part of a token (so `key=value` stays one word). The first
2350/// token (the binary name) is dropped — callers already know it.
2351fn split_line(line: &str, point: usize) -> (Vec<String>, String) {
2352 let point = point.min(line.len());
2353 let head = &line[..point];
2354 let mut words: Vec<String> = Vec::new();
2355 let mut cur = String::new();
2356 let mut in_quote: Option<char> = None;
2357 let mut chars = head.chars().peekable();
2358 while let Some(ch) = chars.next() {
2359 match in_quote {
2360 Some(q) if ch == q => { in_quote = None; }
2361 Some(_) => cur.push(ch),
2362 None => match ch {
2363 '\'' | '"' => { in_quote = Some(ch); }
2364 '\\' => {
2365 if let Some(next) = chars.next() { cur.push(next); }
2366 }
2367 ' ' | '\t' => {
2368 if !cur.is_empty() {
2369 words.push(std::mem::take(&mut cur));
2370 }
2371 }
2372 _ => cur.push(ch),
2373 }
2374 }
2375 }
2376 if !words.is_empty() { words.remove(0); }
2377 (words, cur)
2378}
2379
2380/// Check for completion env vars and handle them.
2381///
2382/// Expects the bash shim emitted by [`print_bash_script`] —
2383/// `<binary> "$COMP_LINE" "$COMP_POINT"` — and tokenizes the
2384/// line in Rust before walking the [`CommandTree`].
2385/// Engine-level diagnostic flags. All start with the triple-dash
2386/// (`---`) prefix to make them visually distinct from normal `--`
2387/// CLI flags and from `-x` short flags. They're never user-facing —
2388/// downstream developers and integration tests use them to introspect
2389/// veks-completion behavior programmatically.
2390///
2391/// **Provider-specific diagnostics live with each provider**, not
2392/// here. For example, the MetricsQL provider in [`crate::providers`]
2393/// exposes its own `metricsql_diagnostic_args` function the embedder
2394/// can call alongside [`handle_diagnostic_args`].
2395///
2396/// See [`handle_diagnostic_args`] for the dispatcher.
2397pub const DIAGNOSTIC_FLAGS: &[&str] = &[
2398 "---help", // list every recognised diagnostic
2399 "---version", // print veks-completion crate version
2400 "---dump-tree", // print the CommandTree as text
2401 "---list-providers", // list subtree providers by path
2402 "---validate", // run CommandTree::validate, print errors
2403 "---trace-completion", // <line> <point>: run completion + print
2404 "---trace-partial-parse", // <line> <point>: print PartialParse state
2405];
2406
2407/// Triple-dash diagnostic dispatcher. Inspect `std::env::args` for a
2408/// recognised `---*` flag (see [`DIAGNOSTIC_FLAGS`]) and, if found,
2409/// run the corresponding diagnostic and return `true`. The caller
2410/// should `process::exit(0)` (or just return) when this returns
2411/// true, exactly like [`handle_complete_env`].
2412///
2413/// All output goes to stdout (so embedders can pipe into a test).
2414/// Output is line-oriented and stable so integration tests can
2415/// match against it.
2416///
2417/// # Why triple-dash?
2418///
2419/// Single-dash (`-x`) and double-dash (`--xyz`) flags belong to the
2420/// downstream app's argv vocabulary. Triple-dash is reserved for
2421/// veks-completion-internal diagnostics; the engine intercepts them
2422/// before normal argv parsing so they can't collide with user flags.
2423///
2424/// # Example downstream usage
2425///
2426/// ```ignore
2427/// fn main() {
2428/// let tree = build_tree();
2429///
2430/// // (1) Tab callback
2431/// if veks_completion::handle_complete_env("myapp", &tree) { return; }
2432///
2433/// // (2) ---* diagnostics — for tests + dev workflow
2434/// if veks_completion::handle_diagnostic_args("myapp", &tree) { return; }
2435///
2436/// // (3) Normal CLI parsing & dispatch
2437/// let parsed = veks_completion::parse_argv(&tree, &collect_argv())?;
2438/// // …
2439/// }
2440/// ```
2441///
2442/// # Flags recognised
2443///
2444/// | Flag | Args | Output |
2445/// |------|------|--------|
2446/// | `---help` | — | List every recognised flag with one-line description |
2447/// | `---version` | — | Crate name + version |
2448/// | `---dump-tree` | — | Pretty-printed tree shape (children, flags, levels) |
2449/// | `---list-providers` | — | Each path that has a `SubtreeProvider` attached |
2450/// | `---validate` | — | Run `CommandTree::validate()`; exit non-zero if errors |
2451/// | `---trace-completion` | `<line> <point>` | Run the completion engine on the synthetic input and print one candidate per line — byte-exact `COMPREPLY` content, including the trailing space on word-complete candidates |
2452/// | `---trace-partial-parse` | `<line> <point>` | Print `PartialParse` state (raw_line, cursor, before/after, bracket_state, ident, trigger) |
2453/// | `---metricsql-vocab` | — | Print built-in MetricsQL vocab (functions, time units, modifiers) |
2454/// | `---metricsql-context` | `<line> <point>` | Same as `---trace-partial-parse` plus the values `metricsql_provider` would derive |
2455pub fn handle_diagnostic_args(app_name: &str, tree: &CommandTree) -> bool {
2456 let argv: Vec<String> = std::env::args().collect();
2457 let flag_idx = argv.iter().position(|a| a.starts_with("---"));
2458 let Some(idx) = flag_idx else { return false; };
2459 let flag = argv[idx].as_str();
2460 let rest: Vec<&str> = argv.iter().skip(idx + 1).map(|s| s.as_str()).collect();
2461 match flag {
2462 "---help" => {
2463 println!("Triple-dash engine options (reserved — never collide with normal");
2464 println!("`--` CLI flags):");
2465 println!();
2466 println!(" Completion stability threshold — put at the START of the line to");
2467 println!(" control which commands tab-completion suggests:");
2468 println!(" ---stable only stable commands");
2469 println!(" ---preview stable + preview commands (default)");
2470 println!(" ---experimental everything, incl. experimental commands");
2471 println!();
2472 println!(" List commands by maturity tier (prints the inventory, runs nothing):");
2473 println!(" ---list-stable commands tagged stable");
2474 println!(" ---list-preview commands tagged preview");
2475 println!(" ---list-experimental commands tagged experimental");
2476 println!();
2477 println!(" Diagnostics (dev / test only):");
2478 for f in DIAGNOSTIC_FLAGS {
2479 println!(" {f}");
2480 }
2481 }
2482 "---version" => {
2483 println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
2484 let _ = app_name;
2485 }
2486 "---dump-tree" => dump_tree(&tree.root, &mut Vec::new()),
2487 "---list-providers" => list_providers(&tree.root, &mut Vec::new()),
2488 "---list-stable" => list_by_stability(&tree.root, &mut Vec::new(), Stability::Stable),
2489 "---list-preview" => list_by_stability(&tree.root, &mut Vec::new(), Stability::Preview),
2490 "---list-experimental" => {
2491 list_by_stability(&tree.root, &mut Vec::new(), Stability::Experimental)
2492 }
2493 "---validate" => match tree.validate() {
2494 Ok(()) => println!("ok"),
2495 Err(errors) => {
2496 for e in errors {
2497 println!("{:?}", e);
2498 }
2499 std::process::exit(1);
2500 }
2501 },
2502 "---trace-completion" => {
2503 let user_line = rest.first().copied().unwrap_or("");
2504 let user_point = rest.get(1).and_then(|s| s.parse().ok());
2505 for c in trace_completion_candidates(app_name, tree, user_line, user_point) {
2506 println!("{c}");
2507 }
2508 }
2509 "---trace-partial-parse" => {
2510 let (line_with_app, point_in_line) = synth_line_for_trace(app_name, &rest);
2511 let (prior, cur) = split_line(&line_with_app, point_in_line);
2512 let prior_owned: Vec<String> = prior;
2513 let cur_owned = cur;
2514 let pp = PartialParse {
2515 completed: prior_owned.iter().map(|s| s.as_str()).collect(),
2516 partial: &cur_owned,
2517 tree_path: Vec::new(),
2518 raw_line: &line_with_app,
2519 cursor_offset: point_in_line,
2520 tap_count: 1,
2521 };
2522 print_partial_parse(&pp);
2523 }
2524 other if other.starts_with("---") => {
2525 // Unknown engine-level flag. Don't claim it — return
2526 // false so downstream provider-specific dispatchers
2527 // get a chance.
2528 return false;
2529 }
2530 _ => return false,
2531 }
2532 true
2533}
2534
2535/// Build a (line, point) pair for the trace diagnostics. The user
2536/// supplies the line as everything-after-the-binary (e.g.
2537/// `"query up{"`); we prepend the binary name + a separating space
2538/// so `split_line`'s "drop first token" assumption holds and the
2539/// engine sees the same shape it would from a real bash invocation.
2540fn synth_line_for_trace(app_name: &str, rest: &[&str]) -> (String, usize) {
2541 let user_line = rest.first().copied().unwrap_or("");
2542 let user_point = rest.get(1).and_then(|s| s.parse().ok());
2543 synth_line(app_name, user_line, user_point)
2544}
2545
2546/// Core of [`synth_line_for_trace`] with the inputs already parsed:
2547/// `user_line` is everything after the binary name, `user_point` the
2548/// byte offset of the cursor within `user_line` (default: its end).
2549fn synth_line(app_name: &str, user_line: &str, user_point: Option<usize>) -> (String, usize) {
2550 let user_point = user_point.unwrap_or(user_line.len());
2551 let prefix_len = app_name.len() + 1; // "metricsql "
2552 (
2553 format!("{} {}", app_name, user_line),
2554 user_point + prefix_len,
2555 )
2556}
2557
2558/// Pure core of the `---trace-completion` diagnostic: run the
2559/// completion engine for `user_line` (the command line as typed
2560/// AFTER the binary name, e.g. `"datasets li"`) with the cursor at
2561/// byte offset `user_point` within it (default: end of line), and
2562/// return exactly the lines the diagnostic prints.
2563///
2564/// The returned strings are the byte-exact candidates bash receives
2565/// in `COMPREPLY` — including the trailing space that
2566/// [`shell_ready_candidates`] bakes into word-complete candidates
2567/// (the hook registers with a global `-o nospace`, so spacing lives
2568/// in the candidate bytes). Unit tests call this directly instead of
2569/// spawning a process or mutating `std::env`.
2570pub fn trace_completion_candidates(
2571 app_name: &str,
2572 tree: &CommandTree,
2573 user_line: &str,
2574 user_point: Option<usize>,
2575) -> Vec<String> {
2576 let (line_with_app, point_in_line) = synth_line(app_name, user_line, user_point);
2577 let (prior, cur) = split_line(&line_with_app, point_in_line);
2578 let mut words_owned: Vec<String> = vec![app_name.to_string()];
2579 words_owned.extend(prior);
2580 words_owned.push(cur);
2581 let words: Vec<&str> = words_owned.iter().map(|s| s.as_str()).collect();
2582 let cands = complete_at_tap_with_raw(tree, &words, 1, &line_with_app, point_in_line);
2583 shell_ready_candidates(tree, &words, cands)
2584}
2585
2586/// Pretty-print the engine's view of a `PartialParse` for the
2587/// `---trace-partial-parse` and downstream provider diagnostic
2588/// flags. Public so provider-specific diagnostic dispatchers
2589/// (e.g. [`crate::providers::metricsql_diagnostic_args`]) can reuse
2590/// the same output format.
2591pub fn print_partial_parse_for_diagnostics(pp: &PartialParse) {
2592 print_partial_parse(pp);
2593}
2594
2595/// Render the value-position help annotation. Called once per
2596/// `complete_rotating` invocation when the cursor sits after a
2597/// value-taking flag. Tier matrix:
2598///
2599/// | tap_count | what we emit |
2600/// |-----------|---------------------------------------------------------------|
2601/// | 1 | nothing — first tap is for candidates |
2602/// | 2 | short help (`flag_help` / `global_flag_help`) |
2603/// | 3 | extended help (`flag_long_help`); short if extended missing |
2604/// | >=4 | nothing — past the rotation, candidate-only again |
2605///
2606/// Every emitted annotation leads with a blank line so it drops
2607/// below the prompt, prefixes every help line with `# ` so it reads
2608/// as a comment, and ends with a one-line ctrl-l hint reminding the
2609/// user how to clear it and restore the command-line view.
2610fn emit_value_position_help(tree: &CommandTree, node: &Node, prev_word: &str, tap_count: u32) {
2611 if let Some(text) = value_position_help(tree, node, prev_word, tap_count) {
2612 eprint!("{text}");
2613 }
2614}
2615
2616/// The value-position help annotation for a given tap count, or `None`
2617/// when nothing should be shown — the pure decision+formatting that
2618/// [`emit_value_position_help`] prints to stderr. Split out so the tier
2619/// table can be unit-tested deterministically (the wall-clock tap-count
2620/// derivation is covered separately by [`next_tap_state`]).
2621///
2622/// The returned block leads with a blank line so it drops below the
2623/// prompt, prefixes every line with `# ` so it reads as a comment, and
2624/// ends with the ctrl-l clear hint.
2625fn value_position_help(
2626 tree: &CommandTree,
2627 node: &Node,
2628 prev_word: &str,
2629 tap_count: u32,
2630) -> Option<String> {
2631 let (label, body): (&str, &str) = match tap_count {
2632 2 => match node
2633 .flag_help_for(prev_word)
2634 .or_else(|| tree.global_flag_help_for(prev_word))
2635 {
2636 Some(h) => ("help", h),
2637 None => return None,
2638 },
2639 3 => {
2640 let extended = node
2641 .flag_long_help_for(prev_word)
2642 .or_else(|| tree.global_flag_long_help_for(prev_word));
2643 match extended {
2644 Some(h) => ("detail", h),
2645 None => match node
2646 .flag_help_for(prev_word)
2647 .or_else(|| tree.global_flag_help_for(prev_word))
2648 {
2649 Some(h) => ("help", h),
2650 None => return None,
2651 },
2652 }
2653 }
2654 _ => return None,
2655 };
2656 let mut out = String::new();
2657 out.push('\n');
2658 out.push_str(&format!("# {prev_word} ({label}):\n"));
2659 for line in body.lines() {
2660 if line.is_empty() {
2661 out.push_str("#\n");
2662 } else {
2663 out.push_str(&format!("# {line}\n"));
2664 }
2665 }
2666 out.push_str("#\n");
2667 out.push_str("# use ctrl-l to clear help and restore the command line view\n");
2668 Some(out)
2669}
2670
2671fn dump_tree(node: &Node, path: &mut Vec<String>) {
2672 let path_str = if path.is_empty() { "/".to_string() } else { format!("/{}", path.join("/")) };
2673 let category = node.category().unwrap_or("");
2674 let level = node.level();
2675 let help_keys: Vec<&String> = node.flag_help.keys().collect();
2676 let provider_keys: Vec<&String> = node.value_providers.keys().collect();
2677 println!("{path_str} level={level} category={category} flags={:?} flag_help={:?} value_providers={:?} has_subtree_provider={} has_extras={}",
2678 node.flags(),
2679 help_keys,
2680 provider_keys,
2681 node.subtree_provider().is_some(),
2682 node.extras().is_some());
2683 for (name, child) in node.children() {
2684 path.push(name.clone());
2685 dump_tree(child, path);
2686 path.pop();
2687 }
2688}
2689
2690fn list_providers(node: &Node, path: &mut Vec<String>) {
2691 if node.subtree_provider().is_some() {
2692 let p = if path.is_empty() { "/".to_string() } else { format!("/{}", path.join("/")) };
2693 println!("{p}");
2694 }
2695 for (name, child) in node.children() {
2696 path.push(name.clone());
2697 list_providers(child, path);
2698 path.pop();
2699 }
2700}
2701
2702/// Print the space-joined path of every command whose stability is *exactly*
2703/// `want`, depth-first. Backs `---list-{stable,preview,experimental}`: a flat,
2704/// scriptable inventory of the commands at one maturity tier.
2705fn list_by_stability(node: &Node, path: &mut Vec<String>, want: Stability) {
2706 if !path.is_empty() && node.stability() == want {
2707 println!("{}", path.join(" "));
2708 }
2709 for (name, child) in node.children() {
2710 path.push(name.clone());
2711 list_by_stability(child, path, want);
2712 path.pop();
2713 }
2714}
2715
2716fn print_partial_parse(pp: &PartialParse) {
2717 println!("raw_line: {:?}", pp.raw_line);
2718 println!("cursor_offset: {}", pp.cursor_offset);
2719 println!("completed: {:?}", pp.completed);
2720 println!("partial: {:?}", pp.partial);
2721 println!("tree_path: {:?}", pp.tree_path);
2722 println!("before_cursor(): {:?}", pp.before_cursor());
2723 println!("after_cursor(): {:?}", pp.after_cursor());
2724 println!("ident_before_cursor(): {:?}", pp.ident_before_cursor());
2725 println!("trigger_char(): {:?}", pp.trigger_char());
2726 let bs = pp.bracket_state();
2727 println!("bracket_state: paren={} brace={} bracket={} inside_quote={:?}",
2728 bs.paren, bs.brace, bs.bracket, bs.inside_quote);
2729 println!("shell_word_start(): {}", pp.shell_word_start());
2730 println!("shell_current_word(): {:?}", pp.shell_current_word());
2731}
2732
2733pub fn handle_complete_env(app_name: &str, tree: &CommandTree) -> bool {
2734 // ONLY the app-namespaced `_<APP>_COMPLETE` diverts execution. We do NOT
2735 // honor a bare global `COMPLETE` env var: it's set by clap_complete-era
2736 // shims and, if it leaks into the environment, would turn every normal
2737 // invocation (`<app> --version`, `<app> serve …`) into a silent completion
2738 // request — a binary that mysteriously stops running. The registration
2739 // script we emit uses `_<APP>_COMPLETE` precisely so it can't collide.
2740 let env_var = format!("_{}_COMPLETE", app_name.to_uppercase().replace('-', "_"));
2741 if std::env::var(&env_var).ok().as_deref() != Some("bash") {
2742 return false;
2743 }
2744
2745 let argv: Vec<String> = std::env::args().collect();
2746
2747 // Activation handshake vs. completion request. A completion callback always
2748 // passes the command line as `argv[1]` (the `$COMP_LINE`/`$COMP_POINT` shim
2749 // in `print_bash_script`). A *bare* `_<APP>_COMPLETE=bash <app>` carries no
2750 // line and means "give me the registration script" — emit the shim rather
2751 // than completing the empty line (which would dump the subcommand list).
2752 if argv.len() <= 1 {
2753 print_bash_script(app_name);
2754 return true;
2755 }
2756
2757 let line = argv.get(1).cloned().unwrap_or_default();
2758 let point: usize = argv.get(2)
2759 .and_then(|s| s.parse().ok())
2760 .unwrap_or(line.len());
2761
2762 let (prior, cur) = split_line(&line, point);
2763
2764 // A leading `---experimental` / `---preview` / `---stable` token sets the
2765 // completion stability threshold for this request. Every `---…` token is
2766 // engine meta — never a subcommand and never itself offered as a candidate —
2767 // so strip all of them from the path words before resolution.
2768 let (threshold, prior) = split_stability_prefix(prior, tree.min_stability);
2769
2770 // Scope the tree to the requested threshold (cheap clone; this process is
2771 // short-lived and runs once per keystroke). Avoid the clone in the common
2772 // case where the threshold is unchanged.
2773 let scoped: CommandTree;
2774 let tree: &CommandTree = if threshold == tree.min_stability {
2775 tree
2776 } else {
2777 let mut t = tree.clone();
2778 t.min_stability = threshold;
2779 scoped = t;
2780 &scoped
2781 };
2782
2783 // The downstream API takes a `words: &[&str]` shape where
2784 // index 0 is the binary name and the last entry is the
2785 // (possibly empty) word under the cursor.
2786 let mut words_owned: Vec<String> = vec![app_name.to_string()];
2787 words_owned.extend(prior);
2788 words_owned.push(cur);
2789 let words: Vec<&str> = words_owned.iter().map(|s| s.as_str()).collect();
2790
2791 let input_key = words[1..].join(" ");
2792
2793 // Determine the max-level of the group the cursor is currently
2794 // inside. `tap_detect` needs this to know when to reset the
2795 // persistent state (after the user has tapped through to the
2796 // last layer).
2797 let completed_for_max: &[&str] = if words.len() > 1 {
2798 &words[1..words.len() - 1]
2799 } else {
2800 &[]
2801 };
2802 let mut max_node = &tree.root;
2803 let mut path_has_subtree_provider = max_node.subtree_provider().is_some();
2804 for &word in completed_for_max {
2805 if let Some(child) = max_node.child(word) {
2806 max_node = child;
2807 if max_node.subtree_provider().is_some() {
2808 path_has_subtree_provider = true;
2809 }
2810 } else {
2811 break;
2812 }
2813 }
2814 // When a subtree provider is on the resolved path, the provider
2815 // owns its own layered output — but the cadence rule still
2816 // gates rapid-tap advancement. The leaf node alone has
2817 // max_level_of_children == 1, which would clamp tap_count to 1
2818 // forever. Bump the cap so providers that layer their output
2819 // by tap (e.g. metricsql tap 1 = metric names, tap 2 = + inner
2820 // functions) actually get a tap_count > 1 on rapid follow-ups.
2821 //
2822 // Same lift applies at a *value position*: when the previous
2823 // word is a value-taking flag (e.g. `--source <TAB>`), the
2824 // tree-shape `max_level == 1` would clamp double-tap to tap=1
2825 // and the value-position help-to-stderr UX would never fire.
2826 // Lift to 2 so a rapid double-tap reaches tap_count == 2 and
2827 // emits the help line.
2828 let at_value_position = words
2829 .iter()
2830 .rev().nth(1)
2831 .and_then(|w| max_node.resolve_flag_token(w))
2832 .map(|w| {
2833 !max_node.boolean_flags.contains(w)
2834 && max_node.flag_help_for(w).is_some()
2835 })
2836 .unwrap_or(false);
2837 let max_level = if path_has_subtree_provider {
2838 SUBTREE_PROVIDER_MAX_TAPS
2839 } else if at_value_position {
2840 // 3 layers: tap 1 = candidates only, tap 2 = short help,
2841 // tap 3 = extended help. See `emit_value_position_help`.
2842 max_level_of_children(max_node).max(1).max(3)
2843 } else {
2844 max_level_of_children(max_node).max(1)
2845 };
2846 let tap_count = tap_detect(app_name, &input_key, max_level);
2847
2848 // Rotating-tier completion with cumulative supersets:
2849 //
2850 // tap 1 (cold) → layer 1
2851 // tap 2 (within 200ms) → layers 1 + 2 (cumulative)
2852 // tap 3 (within 200ms) → layers 1 + 2 + 3 (cumulative, max)
2853 // ↑ persistent state resets here
2854 // tap 4 (within 200ms) → layer 1 (fresh, because state was reset)
2855 // …
2856 //
2857 // Within each cumulative result, candidates are sorted in layer
2858 // order (layer 1 first, then layer 2, …). Trees that haven't
2859 // categorized their commands have max_level == 1, so every tap
2860 // returns the same layer-1 set (no stratification visible).
2861 let candidates = complete_rotating_with_raw(tree, &words, tap_count, &line, point);
2862
2863 // Per-candidate spacing pass: the hook registers with a global
2864 // `-o nospace`, so word-complete candidates must carry their
2865 // trailing space inside the candidate bytes.
2866 for candidate in shell_ready_candidates(tree, &words, candidates) {
2867 println!("{}", candidate);
2868 }
2869
2870 true
2871}
2872
2873/// Window inside which a same-key tap counts as a rapid follow-up
2874/// and advances to the next layer. Outside this window, the next
2875/// tap starts fresh at layer 1. Exposed so embedders can mention or
2876/// match the same value in their own UX.
2877pub const TAP_ADVANCE_MS: u128 = 200;
2878
2879/// Cap on rapid-tap advancement when the resolved completion path
2880/// includes a [`SubtreeProvider`]. The provider owns its candidate
2881/// output and may layer it by tap (e.g. tap 1 = primary set, tap 2
2882/// = primary + secondary), so the engine can't infer a meaningful
2883/// max from tree shape alone. This constant defines how many tap
2884/// tiers the engine will surface to a provider before the cadence
2885/// rule resets back to tap 1.
2886pub const SUBTREE_PROVIDER_MAX_TAPS: u32 = 3;
2887
2888/// Persistable tap state — the bytes a driver would write between
2889/// tap events. Two fields: the wall-clock ms at which the tap
2890/// happened, and the count to *persist* (which is the layer just
2891/// shown, or 0 if we just closed the cycle by hitting `max_level`).
2892///
2893/// Embedders can store a `TapState` in any backing they like (file,
2894/// memory, an in-process map keyed by shell PID, a test fixture).
2895#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2896pub struct TapState {
2897 /// Wall-clock time of the previous tap, in milliseconds since
2898 /// the UNIX epoch (or any monotonic source the embedder picks —
2899 /// the rule only inspects differences).
2900 pub time_ms: u128,
2901 /// Persisted count from the previous tap. `0` means "cycle was
2902 /// just closed; next tap starts fresh"; non-zero means "previous
2903 /// tap showed layer N, advance to N+1 if rapid".
2904 pub count: u32,
2905}
2906
2907/// Pure cadence rule. Given the previous persisted state (and the
2908/// input key it was recorded against), the current time, the current
2909/// input key, and the max layer count for the current group, returns:
2910///
2911/// - `tap_count` — the layer to show on this invocation (the
2912/// value the caller passes to [`complete_rotating`]); always in
2913/// `1..=max_level`.
2914/// - `next` — the [`TapState`] to persist for the *next* tap.
2915///
2916/// Cadence:
2917///
2918/// - A same-key tap within [`TAP_ADVANCE_MS`] of the previous tap
2919/// advances one layer (`prev.count + 1`, capped at `max_level`).
2920/// - Any other tap (cold, idle past the window, or a key change)
2921/// starts fresh at layer 1.
2922/// - Reaching `max_level` resets the persisted count to 0 so the
2923/// next tap (even rapid) starts fresh at layer 1 — closing the
2924/// rotation cycle.
2925///
2926/// Stateless. No file I/O, no clock reads. This is what tests and
2927/// embedders should call directly to script arbitrary timing
2928/// scenarios:
2929///
2930/// ```
2931/// use veks_completion::{TapState, next_tap_state};
2932///
2933/// // Cold start — no previous state.
2934/// let (tap, st) = next_tap_state(None, 1_000, "veks", 2);
2935/// assert_eq!(tap, 1);
2936///
2937/// // Rapid follow-up 100ms later — advances to layer 2.
2938/// let (tap, st) = next_tap_state(Some((st, "veks")), 1_100, "veks", 2);
2939/// assert_eq!(tap, 2);
2940/// // We hit max (2), so persisted count is 0 — cycle closed.
2941/// assert_eq!(st.count, 0);
2942///
2943/// // Third rapid tap — advances from 0+1 = 1 (fresh start).
2944/// let (tap, _) = next_tap_state(Some((st, "veks")), 1_200, "veks", 2);
2945/// assert_eq!(tap, 1);
2946/// ```
2947pub fn next_tap_state(
2948 prev: Option<(TapState, &str)>,
2949 now_ms: u128,
2950 cur_key: &str,
2951 max_level: u32,
2952) -> (u32, TapState) {
2953 let max = max_level.max(1);
2954 let mut tap_count = 1u32;
2955 if let Some((prev_state, prev_key)) = prev
2956 && prev_key == cur_key
2957 && now_ms.saturating_sub(prev_state.time_ms) < TAP_ADVANCE_MS
2958 {
2959 tap_count = prev_state.count.saturating_add(1).min(max);
2960 }
2961 let to_persist = if tap_count >= max { 0 } else { tap_count };
2962 let next = TapState {
2963 time_ms: now_ms,
2964 count: to_persist,
2965 };
2966 (tap_count, next)
2967}
2968
2969/// File-backed driver around [`next_tap_state`]. Reads previous
2970/// state from `/tmp/.<app>_tap_<ppid>`, runs the rule, writes the
2971/// new state back. Used by [`handle_complete_env`] for the standard
2972/// shell-completion flow.
2973///
2974/// Embedders that want different storage (in-memory map, custom
2975/// path, sandboxed tempdir for tests) should call [`next_tap_state`]
2976/// directly and persist the returned [`TapState`] themselves.
2977/// Parent process PID, via `getppid(2)` on unix. Returns `None`
2978/// on platforms without a direct syscall — callers fall back to
2979/// the `_COMP_SHELL_PID` / `PPID` env vars in that case.
2980fn parent_process_id() -> Option<i64> {
2981 #[cfg(unix)]
2982 {
2983 // SAFETY: `getppid` is a thread-safe, side-effect-free
2984 // syscall that returns a `pid_t` (i32 on Linux). Always
2985 // safe to call.
2986 unsafe extern "C" {
2987 fn getppid() -> i32;
2988 }
2989 let pid = unsafe { getppid() };
2990 Some(pid as i64)
2991 }
2992 #[cfg(not(unix))]
2993 {
2994 None
2995 }
2996}
2997
2998fn tap_detect(app_name: &str, input_key: &str, max_level: u32) -> u32 {
2999 use std::io::Write;
3000
3001 // Scope the tap-state file by the parent process PID — i.e.,
3002 // the shell that invoked us. We get this directly from
3003 // `getppid()` rather than asking the shell hook to plumb it in
3004 // via an env var; that keeps the bash hook a strict one-liner.
3005 // Falls back to the env var (or "0") on platforms without a
3006 // syscall, so the cross-platform ladder still works.
3007 let ppid: String = parent_process_id()
3008 .map(|p| p.to_string())
3009 .or_else(|| std::env::var("_COMP_SHELL_PID").ok())
3010 .or_else(|| std::env::var("PPID").ok())
3011 .unwrap_or_else(|| "0".to_string());
3012 let tap_file = std::path::PathBuf::from(format!("/tmp/.{}_tap_{}", app_name, ppid));
3013 let now_ms = std::time::SystemTime::now()
3014 .duration_since(std::time::UNIX_EPOCH)
3015 .map(|d| d.as_millis())
3016 .unwrap_or(0);
3017
3018 // Compare key normalized to the same shape on read AND write so
3019 // a trailing space (common when the user just typed a separator
3020 // before pressing TAB) doesn't trip the same-key check.
3021 let cur_key = input_key.trim_end();
3022
3023 let prev_owned: Option<(TapState, String)> = std::fs::read_to_string(&tap_file)
3024 .ok()
3025 .map(|content| {
3026 let mut parts = content.splitn(3, ' ');
3027 let time_ms: u128 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
3028 let count: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
3029 let key = parts.next().unwrap_or("").trim_end().to_string();
3030 (TapState { time_ms, count }, key)
3031 });
3032 let prev = prev_owned.as_ref().map(|(s, k)| (*s, k.as_str()));
3033
3034 let (tap_count, next) = next_tap_state(prev, now_ms, cur_key, max_level);
3035
3036 if let Ok(mut f) = std::fs::File::create(&tap_file) {
3037 let _ = write!(f, "{} {} {}", next.time_ms, next.count, cur_key);
3038 }
3039
3040 tap_count
3041}
3042
3043// =====================================================================
3044// Directive set adapter (TODO item 10)
3045// =====================================================================
3046
3047/// A richer flag descriptor that bundles the CLI form, optional
3048/// YAML mirror, value semantics, and repeatability into one
3049/// declaration. Adapters can expand a `&[Directive]` into per-flag
3050/// completion + parse rules without the caller writing a parallel
3051/// translation layer.
3052///
3053/// Maps roughly to nbrs's `vocab::Directive` shape — a directive is
3054/// "the canonical statement of what a flag IS, in every surface
3055/// it appears." Use [`apply_directives`] to register a slice of
3056/// directives onto a [`Node`] in one call.
3057#[derive(Debug, Clone)]
3058pub struct Directive {
3059 /// CLI form, e.g. `"--metric"`. Required.
3060 pub cli_flag: &'static str,
3061 /// One-line help text. Optional.
3062 pub help: Option<&'static str>,
3063 /// Closed value set for both completion and validation. `None`
3064 /// means free-form value (or boolean).
3065 pub values: Option<ClosedValues>,
3066 /// `true` for boolean flags (no value expected).
3067 pub boolean: bool,
3068 /// `true` when this flag may appear multiple times. Currently
3069 /// informational; reserved for downstream parsers/validators.
3070 pub repeatable: bool,
3071 /// Optional YAML directive mirror. Currently informational; lets
3072 /// downstream YAML parsers cross-reference the same Directive.
3073 pub yaml_directive: Option<&'static str>,
3074}
3075
3076impl Directive {
3077 /// Construct a value-taking directive with a closed set.
3078 pub const fn closed(
3079 cli_flag: &'static str,
3080 values: &'static [&'static str],
3081 ) -> Self {
3082 Directive {
3083 cli_flag,
3084 help: None,
3085 values: Some(ClosedValues::Static(values)),
3086 boolean: false,
3087 repeatable: false,
3088 yaml_directive: None,
3089 }
3090 }
3091
3092 /// Construct a free-form value-taking directive.
3093 pub const fn value(cli_flag: &'static str) -> Self {
3094 Directive {
3095 cli_flag,
3096 help: None,
3097 values: None,
3098 boolean: false,
3099 repeatable: false,
3100 yaml_directive: None,
3101 }
3102 }
3103
3104 /// Construct a boolean-flag directive.
3105 pub const fn boolean(cli_flag: &'static str) -> Self {
3106 Directive {
3107 cli_flag,
3108 help: None,
3109 values: None,
3110 boolean: true,
3111 repeatable: false,
3112 yaml_directive: None,
3113 }
3114 }
3115
3116 /// Builder: attach help text.
3117 pub const fn with_help(mut self, help: &'static str) -> Self {
3118 self.help = Some(help);
3119 self
3120 }
3121
3122 /// Builder: mark as repeatable.
3123 pub const fn repeatable(mut self) -> Self {
3124 self.repeatable = true;
3125 self
3126 }
3127
3128 /// Builder: attach the YAML mirror directive name.
3129 pub const fn with_yaml(mut self, name: &'static str) -> Self {
3130 self.yaml_directive = Some(name);
3131 self
3132 }
3133}
3134
3135/// Apply a slice of [`Directive`]s to a [`Node`] in one call. Each
3136/// directive becomes:
3137/// - an entry in the node's options/flags list,
3138/// - a `flag_help` entry if `help` is set,
3139/// - a value provider if `values` is set (also feeding validation).
3140///
3141/// Vocab-driven CLIs become a one-liner: declare the directive list
3142/// once, hand it to `apply_directives`, get tab + help + (with
3143/// [`parse_argv`]) parsing all from the same source.
3144pub fn apply_directives(mut node: Node, directives: &[Directive]) -> Node {
3145 let value_flags: Vec<&str> = directives.iter()
3146 .filter(|d| !d.boolean)
3147 .map(|d| d.cli_flag)
3148 .collect();
3149 let bool_flags: Vec<&str> = directives.iter()
3150 .filter(|d| d.boolean)
3151 .map(|d| d.cli_flag)
3152 .collect();
3153
3154 // Add the flags via the unified builders (idempotent: skip
3155 // duplicates).
3156 let value_refs: Vec<&str> = value_flags.to_vec();
3157 let bool_refs: Vec<&str> = bool_flags.to_vec();
3158 node = node.with_flags(&value_refs).with_boolean_flags(&bool_refs);
3159
3160 // Help + value providers via the existing builder methods.
3161 for d in directives {
3162 if let Some(h) = d.help {
3163 node = node.with_flag_help(d.cli_flag, h);
3164 }
3165 if let Some(values) = &d.values {
3166 let provider: ValueProvider = values.clone().into_provider();
3167 node = node.with_value_provider(d.cli_flag, provider);
3168 }
3169 }
3170
3171 node
3172}
3173
3174// =====================================================================
3175// Argv parser companion (TODO item 9)
3176// =====================================================================
3177
3178/// Result of [`parse_argv`] — a structured view of an argv vector
3179/// against a [`CommandTree`]. Embedders use this to dispatch handlers
3180/// without writing a parallel walker.
3181///
3182/// Single source of truth: the same tree that drives tab completion
3183/// drives argv parsing. Add a flag → both completion and parsing
3184/// pick it up. Add a subcommand → both reach it.
3185#[derive(Debug, Clone)]
3186pub struct ParsedCommand<'a> {
3187 /// Path through the tree resolved by argv. `["compute", "knn"]`
3188 /// for `myapp compute knn ...`. Excludes the program name.
3189 pub path: Vec<&'a str>,
3190 /// Flags collected along the way. Multiple values per key when
3191 /// the flag was repeated. Boolean flags map to a single empty
3192 /// string entry.
3193 pub flags: std::collections::BTreeMap<String, Vec<String>>,
3194 /// Positional arguments — anything that wasn't consumed as a
3195 /// flag, flag value, or subcommand name.
3196 pub positionals: Vec<&'a str>,
3197}
3198
3199/// Errors returned by [`parse_argv`].
3200#[derive(Debug, Clone, PartialEq, Eq)]
3201pub enum ParseError {
3202 /// `--flag` was given but the tree expects a value to follow,
3203 /// and argv ended.
3204 MissingValue {
3205 flag: String,
3206 },
3207 /// A flag appeared that no leaf or ancestor declares.
3208 UnknownFlag {
3209 flag: String,
3210 path: Vec<String>,
3211 },
3212 /// A `--flag=value` was given for a closed-set flag whose
3213 /// validator rejected the value. (Caller-driven; this variant is
3214 /// reserved for downstream validators that walk the parse
3215 /// result.)
3216 InvalidValue {
3217 flag: String,
3218 value: String,
3219 },
3220}
3221
3222impl std::fmt::Display for ParseError {
3223 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3224 match self {
3225 ParseError::MissingValue { flag } =>
3226 write!(f, "flag '{}' expects a value but none was given", flag),
3227 ParseError::UnknownFlag { flag, path } =>
3228 write!(f, "unknown flag '{}' at '{}'", flag, path.join(" ")),
3229 ParseError::InvalidValue { flag, value } =>
3230 write!(f, "invalid value '{}' for flag '{}'", value, flag),
3231 }
3232 }
3233}
3234
3235impl std::error::Error for ParseError {}
3236
3237/// Parse `argv` (excluding the program name) against the supplied
3238/// [`CommandTree`]. Walks subcommands, collects flags, separates
3239/// positionals.
3240///
3241/// Strict-ish: rejects flags that aren't declared anywhere along the
3242/// resolved path. Use [`parse_argv_lenient`] when you want unknown
3243/// flags treated as positionals.
3244///
3245/// ```
3246/// use veks_completion::{CommandTree, Node, parse_argv};
3247///
3248/// let tree = CommandTree::new("myapp")
3249/// .command("compute", Node::group(vec![
3250/// ("knn", Node::leaf_with_flags(&["--metric"], &["--verbose"])),
3251/// ]));
3252///
3253/// let parsed = parse_argv(&tree, &[
3254/// "compute", "knn", "--metric", "L2", "--verbose", "input.fvec",
3255/// ]).unwrap();
3256/// assert_eq!(parsed.path, vec!["compute", "knn"]);
3257/// assert_eq!(parsed.flags["--metric"], vec!["L2".to_string()]);
3258/// assert_eq!(parsed.flags["--verbose"], vec!["".to_string()]);
3259/// assert_eq!(parsed.positionals, vec!["input.fvec"]);
3260/// ```
3261pub fn parse_argv<'a>(
3262 tree: &CommandTree,
3263 argv: &[&'a str],
3264) -> Result<ParsedCommand<'a>, ParseError> {
3265 parse_argv_inner(tree, argv, /*lenient=*/ false)
3266}
3267
3268/// Lenient variant of [`parse_argv`] — unknown flags are treated as
3269/// positionals rather than rejected. Useful for "pass-through" CLIs
3270/// that wrap external commands.
3271pub fn parse_argv_lenient<'a>(
3272 tree: &CommandTree,
3273 argv: &[&'a str],
3274) -> Result<ParsedCommand<'a>, ParseError> {
3275 parse_argv_inner(tree, argv, /*lenient=*/ true)
3276}
3277
3278fn parse_argv_inner<'a>(
3279 tree: &CommandTree,
3280 argv: &[&'a str],
3281 lenient: bool,
3282) -> Result<ParsedCommand<'a>, ParseError> {
3283 let mut path: Vec<&'a str> = Vec::new();
3284 let mut flags: std::collections::BTreeMap<String, Vec<String>> =
3285 std::collections::BTreeMap::new();
3286 let mut positionals: Vec<&'a str> = Vec::new();
3287
3288 // Walk the tree, switching nodes when a subcommand name matches
3289 // a child. Flags collected along the way are credited to the
3290 // resolved leaf.
3291 let mut node: &Node = &tree.root;
3292 let mut i = 0usize;
3293 while i < argv.len() {
3294 let arg = argv[i];
3295
3296 // Subcommand match: only when we're at a Group and the next
3297 // argv token is a child name AND the cursor isn't already
3298 // inside a flag-value position.
3299 if let Some(child) = node.child(arg) {
3300 path.push(arg);
3301 node = child;
3302 i += 1;
3303 continue;
3304 }
3305
3306 // `--key=value` form.
3307 if let Some(stripped) = arg.strip_prefix("--") {
3308 if let Some(eq_pos) = stripped.find('=') {
3309 let key = format!("--{}", &stripped[..eq_pos]);
3310 let val = stripped[eq_pos + 1..].to_string();
3311 if flag_is_known(node, &key) {
3312 flags.entry(key).or_default().push(val);
3313 } else if lenient {
3314 positionals.push(arg);
3315 } else {
3316 return Err(ParseError::UnknownFlag {
3317 flag: key,
3318 path: path.iter().map(|s| s.to_string()).collect(),
3319 });
3320 }
3321 i += 1;
3322 continue;
3323 }
3324
3325 // Bare `--flag` form. Look up to see if it's a boolean
3326 // or expects a value.
3327 let key = arg.to_string();
3328 if !flag_is_known(node, &key) {
3329 if lenient {
3330 positionals.push(arg);
3331 i += 1;
3332 continue;
3333 } else {
3334 return Err(ParseError::UnknownFlag {
3335 flag: key,
3336 path: path.iter().map(|s| s.to_string()).collect(),
3337 });
3338 }
3339 }
3340 if flag_is_boolean(node, &key) {
3341 flags.entry(key).or_default().push(String::new());
3342 i += 1;
3343 } else {
3344 if i + 1 >= argv.len() {
3345 return Err(ParseError::MissingValue { flag: key });
3346 }
3347 let val = argv[i + 1].to_string();
3348 flags.entry(key).or_default().push(val);
3349 i += 2;
3350 }
3351 continue;
3352 }
3353
3354 // Single-char `-x` flags get treated as `--x` for now.
3355 // Future: explicit short-flag table on the node.
3356 if arg.starts_with('-') && arg.len() > 1 && !arg.starts_with("--") {
3357 // Treat as positional fallthrough for the additive
3358 // version; short-flag handling is deferred (see TODO).
3359 positionals.push(arg);
3360 i += 1;
3361 continue;
3362 }
3363
3364 // Plain positional.
3365 positionals.push(arg);
3366 i += 1;
3367 }
3368
3369 Ok(ParsedCommand { path, flags, positionals })
3370}
3371
3372fn flag_is_known(node: &Node, flag: &str) -> bool {
3373 // A flag is "known" if the current leaf or group declares it.
3374 node.flags.iter().any(|o| flag_canonical_match(o, flag))
3375}
3376
3377fn flag_is_boolean(node: &Node, flag: &str) -> bool {
3378 node.boolean_flags.contains(flag)
3379}
3380
3381fn flag_canonical_match(declared: &str, given: &str) -> bool {
3382 // Trim a trailing `=` from a declared `--flag=` form before
3383 // comparing — `--metric=` is the declaration shape used in some
3384 // pipeline commands.
3385 let d = declared.trim_end_matches('=');
3386 d == given
3387}
3388
3389/// Expanded completion: show all `group command` pairs.
3390pub fn complete_expanded(tree: &CommandTree, words: &[&str]) -> Vec<String> {
3391 let partial = if words.len() > 1 { words.last().unwrap_or(&"") } else { &"" };
3392 let completed = if words.len() > 2 { &words[1..words.len() - 1] } else { &[] };
3393
3394 if !completed.is_empty() || !partial.is_empty() {
3395 return complete(tree, words);
3396 }
3397
3398 let mut results = Vec::new();
3399 for (name, node) in &tree.root.children {
3400 if name == "help" || name.starts_with('-') {
3401 continue;
3402 }
3403 if !node.children.is_empty() {
3404 for sub_name in node.children.keys() {
3405 results.push(format!("{} {}", name, sub_name));
3406 }
3407 } else if !tree.hidden.contains(name.as_str()) {
3408 results.push(name.to_string());
3409 }
3410 }
3411 results
3412}
3413
3414#[cfg(test)]
3415mod tests {
3416 use super::*;
3417
3418 fn test_tree() -> CommandTree {
3419 CommandTree::new("testapp")
3420 .command("run", Node::leaf_with_flags(
3421 &["cycles=", "threads=", "adapter=", "workload="],
3422 &["--strict", "--tui"],
3423 ).with_dynamic_options(dynamic_workload_params))
3424 .command("bench", Node::group(vec![
3425 ("gk", Node::leaf_with_flags(
3426 &["cycles=", "threads=", "--cycles", "--threads"],
3427 &["--explain"],
3428 )),
3429 ]))
3430 }
3431
3432 /// Test dynamic options provider: if workload=X is on the line,
3433 /// return extra params that the workload declares.
3434 fn dynamic_workload_params(_partial: &str, context: &[&str]) -> Vec<String> {
3435 // Find workload= on the context
3436 for word in context {
3437 if let Some(path) = word.strip_prefix("workload=")
3438 && path == "test_keyvalue.yaml" {
3439 return vec!["keyspace=".into(), "table=".into(), "keycount=".into()];
3440 }
3441 }
3442 Vec::new()
3443 }
3444
3445 #[test]
3446 fn root_completions() {
3447 let tree = test_tree();
3448 let candidates = complete(&tree, &["testapp", ""]);
3449 assert!(candidates.contains(&"bench".to_string()));
3450 assert!(candidates.contains(&"run".to_string()));
3451 }
3452
3453 #[test]
3454 fn run_shows_all_options() {
3455 let tree = test_tree();
3456 let candidates = complete(&tree, &["testapp", "run", ""]);
3457 assert!(candidates.contains(&"cycles=".to_string()));
3458 assert!(candidates.contains(&"--strict".to_string()));
3459 assert!(candidates.contains(&"adapter=".to_string()));
3460 }
3461
3462 #[test]
3463 fn run_filters_consumed_bare_param() {
3464 let tree = test_tree();
3465 let candidates = complete(&tree, &["testapp", "run", "cycles=1000", ""]);
3466 assert!(!candidates.contains(&"cycles=".to_string()));
3467 assert!(candidates.contains(&"threads=".to_string()));
3468 assert!(candidates.contains(&"--strict".to_string()));
3469 }
3470
3471 #[test]
3472 fn run_filters_consumed_flag() {
3473 let tree = test_tree();
3474 let candidates = complete(&tree, &["testapp", "run", "--strict", ""]);
3475 assert!(!candidates.contains(&"--strict".to_string()));
3476 assert!(candidates.contains(&"cycles=".to_string()));
3477 }
3478
3479 #[test]
3480 fn bench_gk_filters_consumed() {
3481 let tree = test_tree();
3482 let candidates = complete(&tree, &["testapp", "bench", "gk", "expr", "--cycles=1000000", "--threads=20", ""]);
3483 assert!(!candidates.contains(&"--cycles".to_string()));
3484 assert!(!candidates.contains(&"cycles=".to_string()));
3485 assert!(!candidates.contains(&"--threads".to_string()));
3486 assert!(!candidates.contains(&"threads=".to_string()));
3487 assert!(candidates.contains(&"--explain".to_string()));
3488 }
3489
3490 #[test]
3491 fn partial_match_bare_param() {
3492 let tree = test_tree();
3493 let candidates = complete(&tree, &["testapp", "run", "cy"]);
3494 assert!(candidates.contains(&"cycles=".to_string()));
3495 assert!(!candidates.contains(&"--strict".to_string()));
3496 }
3497
3498 #[test]
3499 fn dynamic_options_from_workload() {
3500 let tree = test_tree();
3501 // When workload=test_keyvalue.yaml is on the line, dynamic params appear
3502 let candidates = complete(&tree, &["testapp", "run", "workload=test_keyvalue.yaml", ""]);
3503 assert!(candidates.contains(&"keyspace=".to_string()), "dynamic param 'keyspace=' should appear");
3504 assert!(candidates.contains(&"table=".to_string()), "dynamic param 'table=' should appear");
3505 assert!(candidates.contains(&"keycount=".to_string()), "dynamic param 'keycount=' should appear");
3506 // Static options should still be present
3507 assert!(candidates.contains(&"--strict".to_string()));
3508 // workload= should be consumed
3509 assert!(!candidates.contains(&"workload=".to_string()));
3510 }
3511
3512 #[test]
3513 fn dynamic_options_filtered_when_consumed() {
3514 let tree = test_tree();
3515 let candidates = complete(&tree, &["testapp", "run", "workload=test_keyvalue.yaml", "keyspace=mykeyspace", ""]);
3516 assert!(!candidates.contains(&"keyspace=".to_string()), "keyspace= already used");
3517 assert!(candidates.contains(&"table=".to_string()), "table= still available");
3518 }
3519
3520 #[test]
3521 fn dynamic_options_partial_match() {
3522 let tree = test_tree();
3523 let candidates = complete(&tree, &["testapp", "run", "workload=test_keyvalue.yaml", "key"]);
3524 assert!(candidates.contains(&"keyspace=".to_string()));
3525 assert!(candidates.contains(&"keycount=".to_string()));
3526 assert!(!candidates.contains(&"table=".to_string()), "table= doesn't start with 'key'");
3527 }
3528
3529 #[test]
3530 fn no_dynamic_options_without_workload() {
3531 let tree = test_tree();
3532 let candidates = complete(&tree, &["testapp", "run", ""]);
3533 assert!(!candidates.contains(&"keyspace=".to_string()), "no workload= means no dynamic params");
3534 }
3535
3536 #[test]
3537 fn word_matches_exact_flag() {
3538 assert!(word_matches_option("--strict", "--strict"));
3539 assert!(!word_matches_option("--strict", "--tui"));
3540 }
3541
3542 #[test]
3543 fn word_matches_bare_key_value() {
3544 assert!(word_matches_option("cycles=1000", "cycles="));
3545 assert!(!word_matches_option("threads=4", "cycles="));
3546 }
3547
3548 #[test]
3549 fn word_matches_dashed_to_bare_equivalence() {
3550 assert!(word_matches_option("--cycles=1000", "cycles="));
3551 assert!(word_matches_option("cycles=1000", "--cycles"));
3552 }
3553
3554 // ---- stratified tap completion ----
3555
3556 fn stratified_tree() -> CommandTree {
3557 CommandTree::new("nbrs")
3558 .command("run",
3559 Node::leaf(&["--cycles="])
3560 .with_category("workloads").with_level(1))
3561 .command("--inspector",
3562 Node::leaf(&[])
3563 .with_category("tools").with_level(2))
3564 .command("--summary",
3565 Node::leaf(&[])
3566 .with_category("tools").with_level(2))
3567 .command("describe",
3568 Node::leaf(&[])
3569 .with_category("documentation").with_level(3))
3570 .command("bench",
3571 Node::leaf(&[])
3572 .with_category("benchmark").with_level(3))
3573 }
3574
3575 #[test]
3576 fn tap1_shows_only_level1_commands() {
3577 let tree = stratified_tree();
3578 let cands = complete_at_tap(&tree, &["nbrs"], 1);
3579 assert_eq!(cands, vec!["run".to_string()]);
3580 }
3581
3582 #[test]
3583 fn tap2_adds_level2_commands() {
3584 let tree = stratified_tree();
3585 let cands = complete_at_tap(&tree, &["nbrs"], 2);
3586 assert!(cands.contains(&"run".to_string()));
3587 assert!(cands.contains(&"--inspector".to_string()));
3588 assert!(cands.contains(&"--summary".to_string()));
3589 assert!(!cands.contains(&"describe".to_string()),
3590 "level-3 'describe' should not appear at tap 2");
3591 }
3592
3593 #[test]
3594 fn tap3_shows_everything() {
3595 let tree = stratified_tree();
3596 let cands = complete_at_tap(&tree, &["nbrs"], 3);
3597 assert!(cands.contains(&"run".to_string()));
3598 assert!(cands.contains(&"--inspector".to_string()));
3599 assert!(cands.contains(&"describe".to_string()));
3600 assert!(cands.contains(&"bench".to_string()));
3601 }
3602
3603 #[test]
3604 fn level_filter_does_not_block_partial_match() {
3605 // Typing `--ins` should still complete to `--inspector`
3606 // even at tap 1, where level-2 commands aren't shown
3607 // empty-prefix. Once the user has typed a prefix,
3608 // they've signaled intent for that specific command.
3609 let tree = stratified_tree();
3610 let cands = complete_at_tap(&tree, &["nbrs", "--ins"], 1);
3611 assert!(cands.contains(&"--inspector".to_string()),
3612 "partial-prefix matches should bypass the tap-tier filter");
3613 }
3614
3615 #[test]
3616 fn rotating_tap1_baseline_is_layer1_only() {
3617 // The production path is `complete_rotating` (used by
3618 // `handle_complete_env`). At tap=1 it shows only layer-1
3619 // commands, in alphabetical order.
3620 let tree = stratified_tree();
3621 let cands = complete_rotating(&tree, &["nbrs"], 1);
3622 assert_eq!(cands, vec!["run".to_string()]);
3623 }
3624
3625 #[test]
3626 fn rotating_tap2_is_cumulative_superset() {
3627 // At tap=2 (rapid double-tap), the result is the *cumulative
3628 // superset* — every layer-1 command plus every layer-2
3629 // command, never just layer 2 alone.
3630 let tree = stratified_tree();
3631 let cands = complete_rotating(&tree, &["nbrs"], 2);
3632 assert!(cands.contains(&"run".to_string()),
3633 "layer-1 'run' must remain visible at tap 2");
3634 assert!(cands.contains(&"--inspector".to_string()),
3635 "layer-2 '--inspector' must appear at tap 2");
3636 assert!(cands.contains(&"--summary".to_string()),
3637 "layer-2 '--summary' must appear at tap 2");
3638 }
3639
3640 #[test]
3641 fn rotating_tap2_orders_by_layer() {
3642 // Within the cumulative result, candidates must be sorted
3643 // *layer-first* — every layer-1 entry precedes every layer-2
3644 // entry — and within a layer, `--`-flags last + alphabetical.
3645 let tree = stratified_tree();
3646 let cands = complete_rotating(&tree, &["nbrs"], 2);
3647 let pos = |name: &str| cands.iter().position(|s| s == name).unwrap();
3648 assert!(pos("run") < pos("--inspector"),
3649 "layer-1 'run' must precede layer-2 '--inspector'");
3650 assert!(pos("run") < pos("--summary"),
3651 "layer-1 'run' must precede layer-2 '--summary'");
3652 assert!(pos("--inspector") < pos("--summary"),
3653 "within a layer, alphabetical: '--inspector' before '--summary'");
3654 }
3655
3656 #[test]
3657 fn rotating_tap3_at_max_includes_all_layers() {
3658 // At tap=3 (third rapid tap on a 3-layer tree), the
3659 // cumulative result must include every layer 1, 2, and 3
3660 // candidate, in layer order.
3661 let tree = stratified_tree();
3662 let cands = complete_rotating(&tree, &["nbrs"], 3);
3663 assert!(cands.contains(&"run".to_string()));
3664 assert!(cands.contains(&"--inspector".to_string()));
3665 assert!(cands.contains(&"--summary".to_string()));
3666 assert!(cands.contains(&"describe".to_string()));
3667 assert!(cands.contains(&"bench".to_string()));
3668
3669 let pos = |name: &str| cands.iter().position(|s| s == name).unwrap();
3670 // Layer 1 (run) before layer 2 (inspector/summary) before
3671 // layer 3 (bench, describe). `bench` and `describe` are both
3672 // layer 3; alphabetical within the layer keeps `bench` first.
3673 assert!(pos("run") < pos("--inspector"));
3674 assert!(pos("--summary") < pos("bench"));
3675 assert!(pos("--summary") < pos("describe"));
3676 assert!(pos("bench") < pos("describe"));
3677 }
3678
3679 /// Double-tab at a value position must:
3680 /// - leave stdout candidates identical to a single tap, and
3681 /// - emit the flag's help line to stderr (which we can't see
3682 /// in-test, but we *can* assert that the engine takes the
3683 /// help-emitting path without disturbing the stdout list).
3684 ///
3685 /// The shape contract here is the user-visible promise: bash's
3686 /// COMPREPLY is unchanged across rapid taps, and the help line
3687 /// goes somewhere that doesn't pollute the candidate stream.
3688 #[test]
3689 fn rotating_tap2_at_value_position_does_not_pollute_stdout() {
3690 let provider: ValueProvider = std::sync::Arc::new(
3691 |_partial: &str, _ctx: &[&str]| vec!["L2".into(), "IP".into(), "COSINE".into()],
3692 );
3693 let leaf = Node::leaf_with_flags(&["--metric"], &[])
3694 .with_value_provider("--metric", provider)
3695 .with_flag_help("--metric", "Distance metric (L2 / IP / COSINE)");
3696 let tree = CommandTree::new("nbrs").command("run", leaf);
3697
3698 // At a value position (last completed word is the flag,
3699 // partial is empty), tap=1 and tap=2 must produce identical
3700 // stdout candidate lists.
3701 let tap1 = complete_rotating(&tree, &["nbrs", "run", "--metric", ""], 1);
3702 let tap2 = complete_rotating(&tree, &["nbrs", "run", "--metric", ""], 2);
3703 assert_eq!(tap1, tap2, "rapid double-tap must not change the candidate list");
3704 assert!(tap1.contains(&"L2".to_string()));
3705 }
3706
3707 /// At a value position on a leaf with no children, the engine
3708 /// must lift `max_level` to 2 so a rapid double-tap reaches
3709 /// `tap_count == 2`. Otherwise the help-to-stderr branch in
3710 /// the value-position dispatcher never fires.
3711 #[test]
3712 fn value_position_rapid_tap_reaches_tap_count_two() {
3713 // A leaf with one value-taking flag, no children.
3714 let leaf = Node::leaf_with_flags(&["--top-k"], &[])
3715 .with_flag_help("--top-k", "HeavyHitters top-K capacity")
3716 .with_value_provider(
3717 "--top-k",
3718 std::sync::Arc::new(|_p: &str, _c: &[&str]| Vec::new()),
3719 );
3720 let _tree = CommandTree::new("nbrs").command("run", leaf.clone());
3721
3722 // The branch is reached only when the engine grants
3723 // `tap_count >= 2`. `next_tap_state` clamps at `max_level`,
3724 // so without our value-position lift, a leaf with no
3725 // children (max_level = 1) would freeze tap_count at 1
3726 // forever. Simulate the lift: max_level=2 → second rapid
3727 // tap returns 2.
3728 let max_level = 2;
3729 let key = "nbrs run --top-k ";
3730 let (tap1, st1) = next_tap_state(None, 1_000, key, max_level);
3731 assert_eq!(tap1, 1, "first tap is always 1");
3732 let (tap2, _st2) = next_tap_state(Some((st1, key)), 1_100, key, max_level);
3733 assert_eq!(tap2, 2, "rapid second tap must reach 2 at a value position");
3734 }
3735
3736 /// The value-position help *tier table* — deterministic, no clock,
3737 /// no subprocess. Replaces the wall-clock double/triple-tap E2E
3738 /// tests (which raced two real process spawns against a 200ms
3739 /// window): the tap-count derivation is covered by `next_tap_state`
3740 /// above, and this covers what each tap count emits.
3741 #[test]
3742 fn value_position_help_tier_table() {
3743 let leaf = Node::leaf_with_flags(&["--top-k"], &[])
3744 .with_flag_help("--top-k", "HeavyHitters top-K capacity")
3745 .with_flag_long_help("--top-k", "Misra-Gries detail body\n\nsecond paragraph");
3746 let tree = CommandTree::new("nbrs").command("run", leaf.clone());
3747
3748 // tap 1: candidates only — no help.
3749 assert!(value_position_help(&tree, &leaf, "--top-k", 1).is_none());
3750
3751 // tap 2: short help, comment-prefixed, leading blank line + ctrl-l hint.
3752 let s2 = value_position_help(&tree, &leaf, "--top-k", 2).expect("short help at tap 2");
3753 assert!(s2.starts_with("\n# "), "leads with newline + '# ': {s2:?}");
3754 assert!(s2.contains("--top-k (help):"));
3755 assert!(s2.contains("HeavyHitters top-K capacity"));
3756 assert!(s2.contains("use ctrl-l to clear help"));
3757
3758 // tap 3: extended help, labelled (detail).
3759 let s3 = value_position_help(&tree, &leaf, "--top-k", 3).expect("extended help at tap 3");
3760 assert!(s3.contains("--top-k (detail):"));
3761 assert!(s3.contains("Misra-Gries detail body"));
3762 // empty body lines render as a bare '#'.
3763 assert!(s3.contains("\n#\n"));
3764
3765 // tap >= 4: past the rotation — nothing again.
3766 assert!(value_position_help(&tree, &leaf, "--top-k", 4).is_none());
3767
3768 // tap 3 with no extended help falls back to the short help.
3769 let short_only = Node::leaf_with_flags(&["--x"], &[]).with_flag_help("--x", "short only");
3770 let t2 = CommandTree::new("a").command("b", short_only.clone());
3771 let s = value_position_help(&t2, &short_only, "--x", 3).expect("fallback to short");
3772 assert!(s.contains("--x (help):") && s.contains("short only"));
3773
3774 // A flag with no help at all emits nothing, even at tap 2.
3775 let no_help = Node::leaf_with_flags(&["--bare"], &[]);
3776 let t3 = CommandTree::new("a").command("b", no_help.clone());
3777 assert!(value_position_help(&t3, &no_help, "--bare", 2).is_none());
3778 }
3779
3780 /// Flag help registered on a leaf is recoverable via the public
3781 /// accessor — guards the wire path that the value-position
3782 /// rapid-tap UX depends on.
3783 #[test]
3784 fn flag_help_round_trips_through_leaf() {
3785 let leaf = Node::leaf_with_flags(&["--metric"], &[])
3786 .with_flag_help("--metric", "Distance metric");
3787 assert_eq!(leaf.flag_help_for("--metric"), Some("Distance metric"));
3788 assert!(leaf.flag_help_for("--unknown").is_none());
3789 }
3790
3791 /// Global flag help is recoverable on the CommandTree.
3792 #[test]
3793 fn global_flag_help_round_trips() {
3794 let tree = CommandTree::new("nbrs")
3795 .global_flag_help("--dataset", "Dataset name in the configured catalogs");
3796 assert_eq!(
3797 tree.global_flag_help_for("--dataset"),
3798 Some("Dataset name in the configured catalogs"),
3799 );
3800 assert!(tree.global_flag_help_for("--missing").is_none());
3801 }
3802
3803 // ---- pure-rule cadence scenarios (no clock, no file I/O) ----
3804
3805 /// Drive `next_tap_state` through a sequence of taps the same
3806 /// way an embedder would: own the state in a local variable, call
3807 /// the pure function with simulated times. Returns the sequence
3808 /// of `tap_count` values shown across the script.
3809 fn replay_taps(
3810 max_level: u32,
3811 key: &str,
3812 events: &[u128], // wall-clock times of successive taps
3813 ) -> Vec<u32> {
3814 let mut state: Option<TapState> = None;
3815 let mut shown = Vec::with_capacity(events.len());
3816 for &t in events {
3817 let prev = state.map(|s| (s, key));
3818 let (tap, next) = next_tap_state(prev, t, key, max_level);
3819 shown.push(tap);
3820 state = Some(next);
3821 }
3822 shown
3823 }
3824
3825 #[test]
3826 fn cadence_cold_tap_is_layer1() {
3827 let shown = replay_taps(3, "veks", &[1_000]);
3828 assert_eq!(shown, vec![1]);
3829 }
3830
3831 #[test]
3832 fn cadence_rapid_advances_through_layers() {
3833 // Three rapid taps with a 3-layer tree should walk 1 → 2 → 3.
3834 let shown = replay_taps(3, "veks", &[1_000, 1_100, 1_200]);
3835 assert_eq!(shown, vec![1, 2, 3]);
3836 }
3837
3838 #[test]
3839 fn cadence_rapid_past_max_resets_cycle() {
3840 // Four rapid taps with a 3-layer tree: 1 → 2 → 3 → 1
3841 // (the fourth tap reads the reset state and starts fresh).
3842 let shown = replay_taps(3, "veks", &[1_000, 1_100, 1_200, 1_300]);
3843 assert_eq!(shown, vec![1, 2, 3, 1]);
3844 }
3845
3846 #[test]
3847 fn cadence_pause_resets_to_layer1() {
3848 // Two taps separated by 500ms (>200ms window) — the second
3849 // is treated as a fresh start, not a rapid follow-up.
3850 let shown = replay_taps(3, "veks", &[1_000, 1_500]);
3851 assert_eq!(shown, vec![1, 1]);
3852 }
3853
3854 #[test]
3855 fn cadence_key_change_resets_to_layer1() {
3856 // Two rapid taps but the input key changed — second tap is
3857 // a fresh start.
3858 let (t1, st1) = next_tap_state(None, 1_000, "veks", 3);
3859 let state = Some(st1);
3860 assert_eq!(t1, 1);
3861
3862 let prev = state.map(|s| (s, "veks"));
3863 let (t2, _) = next_tap_state(prev, 1_100, "veks compute", 3);
3864 // Different key — fresh start, layer 1.
3865 assert_eq!(t2, 1);
3866 }
3867
3868 #[test]
3869 fn cadence_max_level_2_alternates() {
3870 // With max_level = 2, sustained rapid tapping should
3871 // alternate 1 → 2 → 1 → 2 …
3872 let shown = replay_taps(2, "veks", &[1_000, 1_100, 1_200, 1_300, 1_400]);
3873 assert_eq!(shown, vec![1, 2, 1, 2, 1]);
3874 }
3875
3876 #[test]
3877 fn cadence_max_level_1_pinned() {
3878 // With max_level = 1 (no stratification), every tap shows
3879 // layer 1 — the rotation is a no-op.
3880 let shown = replay_taps(1, "veks", &[1_000, 1_100, 1_200, 1_300]);
3881 assert_eq!(shown, vec![1, 1, 1, 1]);
3882 }
3883
3884 #[test]
3885 fn cadence_advance_window_boundary() {
3886 // Exactly TAP_ADVANCE_MS apart should NOT count as rapid
3887 // (strict less-than comparison). Just under should.
3888 let shown = replay_taps(3, "veks", &[1_000, 1_000 + TAP_ADVANCE_MS]);
3889 assert_eq!(shown, vec![1, 1], "tap exactly at the boundary is fresh");
3890 let shown = replay_taps(3, "veks", &[1_000, 1_000 + TAP_ADVANCE_MS - 1]);
3891 assert_eq!(shown, vec![1, 2], "tap just inside the boundary is rapid");
3892 }
3893
3894 // ---- TODO item 1 + 5: ClosedValues -----------------------------
3895
3896 #[test]
3897 fn closed_values_static_completes_and_validates() {
3898 let cv = ClosedValues::Static(&["L2", "IP", "COSINE"]);
3899 assert_eq!(cv.complete(""), vec!["L2", "IP", "COSINE"]);
3900 assert_eq!(cv.complete("CO"), vec!["COSINE"]);
3901 assert_eq!(cv.complete("Z"), Vec::<String>::new());
3902 assert!(cv.validate("L2"));
3903 assert!(cv.validate("COSINE"));
3904 assert!(!cv.validate("bogus"));
3905 }
3906
3907 #[test]
3908 fn closed_values_owned_completes_and_validates() {
3909 let cv = ClosedValues::Owned(vec!["alpha".into(), "beta".into(), "gamma".into()]);
3910 assert_eq!(cv.complete("a"), vec!["alpha"]);
3911 assert!(cv.validate("beta"));
3912 assert!(!cv.validate("delta"));
3913 }
3914
3915 #[test]
3916 fn closed_values_into_provider_filters() {
3917 let cv = ClosedValues::Static(&["a", "ab", "abc"]);
3918 let provider: ValueProvider = cv.into_provider();
3919 assert_eq!(provider("ab", &[]), vec!["ab", "abc"]);
3920 }
3921
3922 // ---- TODO item 4: alias slice ----------------------------------
3923
3924 #[test]
3925 fn aliases_share_one_provider() {
3926 let provider: ValueProvider = std::sync::Arc::new(|partial: &str, _| {
3927 ["red", "green", "blue"].iter()
3928 .filter(|s| s.starts_with(partial))
3929 .map(|s| s.to_string())
3930 .collect()
3931 });
3932 let leaf = Node::leaf(&["--color", "--colour", "--col"])
3933 .with_value_provider_aliases(&["--color", "--colour", "--col"], provider);
3934 // Each alias resolves to the same provider — we verify by
3935 // confirming all three names complete identically.
3936 let tree = CommandTree::new("paint").command("draw", leaf);
3937 let out_a = complete(&tree, &["paint", "draw", "--color", "g"]);
3938 let out_b = complete(&tree, &["paint", "draw", "--colour", "g"]);
3939 let out_c = complete(&tree, &["paint", "draw", "--col", "g"]);
3940 assert_eq!(out_a, vec!["green"]);
3941 assert_eq!(out_a, out_b);
3942 assert_eq!(out_b, out_c);
3943 }
3944
3945 // ---- TODO item 6: help text + render_usage ---------------------
3946
3947 #[test]
3948 fn render_usage_includes_help_flags_and_subcommands() {
3949 let leaf = Node::leaf_with_flags(&["--metric"], &["--verbose"])
3950 .with_help("Compute KNN over base vectors")
3951 .with_flag_help("--metric", "Distance metric: L2 / IP / COSINE")
3952 .with_flag_help("--verbose", "Print per-step progress");
3953 let tree = CommandTree::new("app")
3954 .command("compute", Node::group(vec![
3955 ("knn", leaf),
3956 ]).with_help("Compute commands"));
3957
3958 let knn_node = tree.root.child("compute").unwrap().child("knn").unwrap();
3959 let out = render_usage(knn_node, &["app", "compute", "knn"]);
3960 assert!(out.contains("USAGE: app compute knn"), "{}", out);
3961 assert!(out.contains("Compute KNN over base vectors"), "{}", out);
3962 assert!(out.contains("--metric"), "{}", out);
3963 assert!(out.contains("Distance metric"), "{}", out);
3964 assert!(out.contains("--verbose"), "{}", out);
3965
3966 let compute_node = tree.root.child("compute").unwrap();
3967 let out = render_usage(compute_node, &["app", "compute"]);
3968 assert!(out.contains("Compute commands"));
3969 assert!(out.contains("SUBCOMMANDS:"));
3970 assert!(out.contains("knn"));
3971 }
3972
3973 // ---- TODO item 3 (additive): group flags -----------------------
3974
3975 // ---- built-in option: with_auto_help --------------------------
3976
3977 #[test]
3978 fn with_auto_help_attaches_help_flag_recursively() {
3979 let tree = CommandTree::new("app")
3980 .command("compute", Node::group(vec![
3981 ("knn", Node::leaf(&["--metric"])),
3982 ]))
3983 .command("run", Node::leaf(&["--input"]))
3984 .with_auto_help();
3985
3986 let knn = tree.root.child("compute").unwrap().child("knn").unwrap();
3987 assert!(knn.flags().iter().any(|f| f == "--help"),
3988 "leaf 'knn' should have --help auto-attached");
3989 assert!(knn.is_flag("--help"), "--help should be boolean");
3990 assert!(knn.flag_help_for("--help").is_some());
3991
3992 let compute = tree.root.child("compute").unwrap();
3993 assert!(compute.flags().iter().any(|f| f == "--help"),
3994 "group 'compute' should have --help auto-attached");
3995
3996 let run = tree.root.child("run").unwrap();
3997 assert!(run.flags().iter().any(|f| f == "--help"));
3998
3999 // --help is now a known flag for the parser too.
4000 let parsed = parse_argv(&tree, &["compute", "knn", "--help"]).unwrap();
4001 assert_eq!(parsed.path, vec!["compute", "knn"]);
4002 assert!(parsed.flags.contains_key("--help"));
4003 }
4004
4005 #[test]
4006 fn with_auto_help_doesnt_double_register() {
4007 let tree = CommandTree::new("app")
4008 .command("run", Node::leaf_with_flags(&[], &["--help"]))
4009 .with_auto_help();
4010 let run = tree.root.child("run").unwrap();
4011 let count = run.flags().iter().filter(|f| **f == "--help").count();
4012 assert_eq!(count, 1, "auto-help must be idempotent");
4013 }
4014
4015 // ---- built-in option: with_metricsql_at -----------------------
4016
4017 #[test]
4018 fn with_metricsql_at_attaches_provider() {
4019 use std::sync::Arc;
4020 struct EmptyCatalog;
4021 impl crate::providers::MetricsqlCatalog for EmptyCatalog {
4022 fn metric_names(&self, p: &str) -> Vec<String> {
4023 ["up", "node_cpu"].iter()
4024 .filter(|n| n.starts_with(p))
4025 .map(|s| s.to_string())
4026 .collect()
4027 }
4028 fn label_keys(&self, _: &str, p: &str) -> Vec<String> {
4029 ["job", "instance"].iter()
4030 .filter(|n| n.starts_with(p))
4031 .map(|s| s.to_string())
4032 .collect()
4033 }
4034 fn label_values(&self, _: &str, _: &str, p: &str) -> Vec<String> {
4035 ["prometheus"].iter()
4036 .filter(|n| n.starts_with(p))
4037 .map(|s| s.to_string())
4038 .collect()
4039 }
4040 }
4041
4042 let tree = CommandTree::new("nbrs")
4043 .command("query", Node::leaf(&[]))
4044 .with_metricsql_at(&["query"], Arc::new(EmptyCatalog));
4045
4046 // Verify the subtree provider was attached.
4047 let query = tree.root.child("query").unwrap();
4048 assert!(query.subtree_provider().is_some(),
4049 "with_metricsql_at must attach a subtree provider");
4050 }
4051
4052 #[test]
4053 fn hybrid_node_completes_children_and_flags_together() {
4054 // A node that has BOTH children AND flags — the central
4055 // capability the unified Node was built for. `report` here
4056 // accepts `--workload` (a group-level flag) AND has a
4057 // `base` subcommand. Tab at root-after-`report` should
4058 // offer both kinds of candidates.
4059 let report = Node::group(vec![
4060 ("base", Node::leaf(&[])),
4061 ("filtered", Node::leaf(&[])),
4062 ])
4063 .with_flags(&["--workload"])
4064 .with_boolean_flags(&["--dry-run"]);
4065 let tree = CommandTree::new("app").command("report", report);
4066
4067 // Empty partial: subcommands first, then flags.
4068 let cands = complete(&tree, &["app", "report", ""]);
4069 let pos = |name: &str| cands.iter().position(|s| s == name);
4070 assert!(pos("base").is_some(), "expected 'base' subcommand: {:?}", cands);
4071 assert!(pos("filtered").is_some(), "expected 'filtered' subcommand: {:?}", cands);
4072 assert!(pos("--workload").is_some(), "expected '--workload' flag: {:?}", cands);
4073 assert!(pos("--dry-run").is_some(), "expected '--dry-run' flag: {:?}", cands);
4074 // Subcommands precede flags.
4075 assert!(pos("base").unwrap() < pos("--workload").unwrap());
4076 assert!(pos("filtered").unwrap() < pos("--dry-run").unwrap());
4077
4078 // `--workload <TAB>` defers to the parser's general value
4079 // path (no provider attached → empty) — the important
4080 // part is that it doesn't fall back to listing children.
4081 let cands = complete(&tree, &["app", "report", "--workload", ""]);
4082 assert!(cands.is_empty() || !cands.iter().any(|c| c == "base"),
4083 "value position must not list children: {:?}", cands);
4084 }
4085
4086 #[test]
4087 fn group_flags_appear_via_options_accessor() {
4088 let group = Node::group(vec![
4089 ("sub", Node::leaf(&[])),
4090 ])
4091 .with_flags(&["--workload"])
4092 .with_boolean_flags(&["--dry-run"]);
4093 assert!(group.options().contains(&"--workload"));
4094 assert!(group.options().contains(&"--dry-run"));
4095 // Hybrid node — has both children and flags. is_group()
4096 // returns true; is_leaf() returns false.
4097 assert!(group.is_group());
4098 assert!(!group.is_leaf());
4099 }
4100
4101 // ---- TODO item 7: subtree provider -----------------------------
4102
4103 #[test]
4104 fn subtree_provider_takes_over_completion() {
4105 // Register a context-aware provider on the `metrics`
4106 // subtree. The provider sees a structured PartialParse and
4107 // returns its own candidates.
4108 let provider: SubtreeProvider = std::sync::Arc::new(|pp: &PartialParse| {
4109 // Return the partial echoed plus the path joined with `:`.
4110 vec![format!("{}:{}", pp.tree_path.join("/"), pp.partial)]
4111 });
4112 let tree = CommandTree::new("app")
4113 .command("metrics",
4114 Node::group(vec![("match", Node::leaf(&[]))])
4115 .with_subtree_provider(provider));
4116
4117 // Cursor inside the metrics subtree.
4118 let out = complete(&tree, &["app", "metrics", "match", "foo"]);
4119 assert_eq!(out, vec!["metrics/match:foo".to_string()]);
4120 }
4121
4122 #[test]
4123 fn rapid_tap_threads_full_count_through_subtree_provider() {
4124 // Regression: when a node on the resolved path carries a
4125 // SubtreeProvider, complete_rotating_with_raw must bypass
4126 // the modulo-by-max-children rotation and pass the FULL
4127 // tap_count to the provider — otherwise providers that
4128 // layer their output by tap (metricsql shows metric names
4129 // at tap 1, adds inner functions at tap 2) never see anything
4130 // beyond tap 1.
4131 let provider: SubtreeProvider = std::sync::Arc::new(|pp: &PartialParse| {
4132 vec![format!("tap={}", pp.tap_count)]
4133 });
4134 let tree = CommandTree::new("app")
4135 .command("query",
4136 Node::leaf(&[]).with_subtree_provider(provider));
4137
4138 let words = ["app", "query", ""];
4139 for tap in [1u32, 2, 3, 7] {
4140 let out = complete_rotating_with_raw(&tree, &words, tap, "app query ", 10);
4141 assert_eq!(out, vec![format!("tap={}", tap)],
4142 "tap_count {tap} did not reach the subtree provider: {out:?}");
4143 }
4144 }
4145
4146 // ---- TODO item 8: extras attachment ----------------------------
4147
4148 #[test]
4149 fn extras_round_trip_via_downcast() {
4150 #[derive(Debug, PartialEq, Eq)]
4151 struct Handler(u32);
4152 let leaf = Node::leaf(&[])
4153 .with_extras(Extras::new(Handler(42)));
4154 let h: &Handler = leaf.extras().unwrap().downcast::<Handler>().unwrap();
4155 assert_eq!(h.0, 42);
4156 // Downcast to a different type returns None.
4157 assert!(leaf.extras().unwrap().downcast::<u8>().is_none());
4158 }
4159
4160 // ---- TODO item 9: argv parser ----------------------------------
4161
4162 #[test]
4163 fn parse_argv_walks_subcommands_and_collects_flags() {
4164 let tree = CommandTree::new("app")
4165 .command("compute", Node::group(vec![
4166 ("knn", Node::leaf_with_flags(&["--metric"], &["--verbose"])),
4167 ]));
4168 let parsed = parse_argv(&tree, &[
4169 "compute", "knn", "--metric", "L2", "--verbose", "data.fvec",
4170 ]).unwrap();
4171 assert_eq!(parsed.path, vec!["compute", "knn"]);
4172 assert_eq!(parsed.flags["--metric"], vec!["L2".to_string()]);
4173 assert_eq!(parsed.flags["--verbose"], vec!["".to_string()]);
4174 assert_eq!(parsed.positionals, vec!["data.fvec"]);
4175 }
4176
4177 #[test]
4178 fn parse_argv_handles_eq_form() {
4179 let tree = CommandTree::new("app")
4180 .command("run", Node::leaf(&["--name"]));
4181 let parsed = parse_argv(&tree, &["run", "--name=foo"]).unwrap();
4182 assert_eq!(parsed.flags["--name"], vec!["foo".to_string()]);
4183 }
4184
4185 #[test]
4186 fn parse_argv_repeats_collect_into_vec() {
4187 let tree = CommandTree::new("app")
4188 .command("run", Node::leaf(&["--set"]));
4189 let parsed = parse_argv(&tree, &[
4190 "run", "--set", "a=1", "--set", "b=2",
4191 ]).unwrap();
4192 assert_eq!(parsed.flags["--set"], vec!["a=1".to_string(), "b=2".to_string()]);
4193 }
4194
4195 #[test]
4196 fn parse_argv_unknown_flag_strict_errors() {
4197 let tree = CommandTree::new("app")
4198 .command("run", Node::leaf(&["--known"]));
4199 let err = parse_argv(&tree, &["run", "--bogus", "x"]).unwrap_err();
4200 assert!(matches!(err, ParseError::UnknownFlag { .. }));
4201 }
4202
4203 #[test]
4204 fn parse_argv_unknown_flag_lenient_falls_through() {
4205 let tree = CommandTree::new("app")
4206 .command("run", Node::leaf(&["--known"]));
4207 let parsed = parse_argv_lenient(&tree, &["run", "--bogus", "x"]).unwrap();
4208 // `--bogus` was treated as positional; `x` followed.
4209 assert_eq!(parsed.positionals, vec!["--bogus", "x"]);
4210 }
4211
4212 #[test]
4213 fn parse_argv_missing_value_errors() {
4214 let tree = CommandTree::new("app")
4215 .command("run", Node::leaf(&["--name"]));
4216 let err = parse_argv(&tree, &["run", "--name"]).unwrap_err();
4217 assert!(matches!(err, ParseError::MissingValue { .. }));
4218 }
4219
4220 // ---- TODO item 10: directive set adapter -----------------------
4221
4222 #[test]
4223 fn apply_directives_registers_flags_help_and_providers() {
4224 const DIRS: &[Directive] = &[
4225 Directive::closed("--metric", &["L2", "IP", "COSINE"])
4226 .with_help("Distance metric"),
4227 Directive::value("--name").with_help("Name of the run"),
4228 Directive::boolean("--verbose").with_help("Verbose output"),
4229 ];
4230 let leaf = apply_directives(Node::leaf(&[]), DIRS);
4231 let tree = CommandTree::new("app").command("run", leaf);
4232
4233 // Tab completion picks up the closed-set values.
4234 let out = complete(&tree, &["app", "run", "--metric", "C"]);
4235 assert_eq!(out, vec!["COSINE"]);
4236
4237 // Help text is reachable via flag_help_for.
4238 let leaf = tree.root.child("run").unwrap();
4239 assert_eq!(leaf.flag_help_for("--metric"), Some("Distance metric"));
4240 assert_eq!(leaf.flag_help_for("--verbose"), Some("Verbose output"));
4241
4242 // The boolean flag is recognized as such by the parser.
4243 let parsed = parse_argv(&tree, &[
4244 "run", "--metric", "L2", "--verbose", "--name", "test",
4245 ]).unwrap();
4246 assert_eq!(parsed.flags["--metric"], vec!["L2".to_string()]);
4247 assert_eq!(parsed.flags["--verbose"], vec!["".to_string()]);
4248 assert_eq!(parsed.flags["--name"], vec!["test".to_string()]);
4249 }
4250
4251 #[test]
4252 fn nodes_without_level_default_to_visible() {
4253 // Backward-compat: a node that never called
4254 // `with_level` resolves to DEFAULT_LEVEL = 1 and is
4255 // visible from tap 1. Apps that haven't migrated to
4256 // categorized completion see no behavior change.
4257 let tree = CommandTree::new("legacy")
4258 .command("run", Node::leaf(&[]))
4259 .command("describe", Node::leaf(&[]));
4260 let cands = complete_at_tap(&tree, &["legacy"], 1);
4261 assert!(cands.contains(&"run".to_string()));
4262 assert!(cands.contains(&"describe".to_string()));
4263 }
4264
4265 // ---- strict-metadata: type-state enforcement ----
4266
4267 #[test]
4268 fn strict_node_with_full_metadata_compiles() {
4269 // The success case — adding a fully-tagged node
4270 // through `strict_command` is the canonical strict
4271 // mode usage. The compiler doesn't reject this.
4272 let _tree = CommandTree::new("app")
4273 .strict_command(
4274 "run",
4275 StrictNode::leaf(&["--cycles="])
4276 .with_category("workloads")
4277 .with_level(1),
4278 );
4279 }
4280
4281 // The next two are intentionally `#[ignore]`d compile-fail
4282 // demonstrations. They live here as documentation rather
4283 // than tests, since `cargo test` won't try to build them
4284 // unless explicitly invoked, but a reader can uncomment to
4285 // verify the gate fires.
4286 //
4287 // ```compile_fail,ignore
4288 // CommandTree::new("app").strict_command(
4289 // "bad",
4290 // StrictNode::leaf(&[]).with_category("x"), // missing with_level
4291 // );
4292 // ```
4293 //
4294 // ```compile_fail,ignore
4295 // CommandTree::new("app").strict_command(
4296 // "bad",
4297 // StrictNode::leaf(&[]).with_level(1), // missing with_category
4298 // );
4299 // ```
4300
4301 // ---- runtime-validation path ----
4302
4303 #[test]
4304 fn runtime_validate_reports_missing_metadata() {
4305 let tree = CommandTree::new("app")
4306 .command("run",
4307 Node::leaf(&[]).with_category("workloads").with_level(1))
4308 .command("undertagged", Node::leaf(&[]));
4309 let errors = tree.validate().unwrap_err();
4310 assert!(errors.iter().any(|e| matches!(e,
4311 MetadataError::MissingCategory { command } if command == "undertagged")));
4312 assert!(errors.iter().any(|e| matches!(e,
4313 MetadataError::MissingLevel { command } if command == "undertagged")));
4314 // The properly-tagged 'run' should not appear in errors.
4315 assert!(!errors.iter().any(|e| matches!(e,
4316 MetadataError::MissingCategory { command } if command == "run")));
4317 }
4318
4319 #[test]
4320 fn runtime_validate_passes_when_all_tagged() {
4321 let tree = CommandTree::new("app")
4322 .command("run",
4323 Node::leaf(&[]).with_category("workloads").with_level(1))
4324 .command("describe",
4325 Node::leaf(&[]).with_category("docs").with_level(3));
4326 assert!(tree.validate().is_ok());
4327 }
4328
4329 #[test]
4330 #[should_panic(expected = "without Node::with_category")]
4331 fn require_metadata_panics_on_undertagged_command() {
4332 let _tree = CommandTree::new("app")
4333 .require_metadata()
4334 .command("bad", Node::leaf(&[])); // missing both
4335 }
4336
4337 // ---- shell emission: per-candidate trailing space ---------------
4338 //
4339 // The bash hook registers with a global `-o nospace`
4340 // (print_bash_script), so readline never appends a space — any
4341 // trailing space must travel inside the candidate bytes. These
4342 // tests drive `trace_completion_candidates`, the pure surface
4343 // behind the `---trace-completion` diagnostic, whose output is
4344 // byte-identical to the COMPREPLY content handle_complete_env
4345 // hands bash.
4346
4347 fn vd_like_tree() -> CommandTree {
4348 CommandTree::new("vd")
4349 .command("datasets", Node::group(vec![
4350 ("list", Node::leaf(&[])),
4351 ("fetch", Node::leaf(&[])),
4352 ]))
4353 .command("run", Node::leaf_with_flags(&["cycles="], &["--strict"]))
4354 }
4355
4356 #[test]
4357 fn trace_unique_subcommand_carries_trailing_space() {
4358 // Repro: `vd datasets li<TAB>` used to complete to `list`
4359 // with no trailing space, leaving the cursor glued to the
4360 // word and forcing the user to type the space themselves.
4361 let tree = vd_like_tree();
4362 let cands = trace_completion_candidates("vd", &tree, "datasets li", None);
4363 assert_eq!(cands, vec!["list ".to_string()],
4364 "a uniquely-completed subcommand is TERMINAL and must carry its trailing space");
4365 }
4366
4367 #[test]
4368 fn trace_group_command_carries_trailing_space() {
4369 let tree = vd_like_tree();
4370 let cands = trace_completion_candidates("vd", &tree, "data", None);
4371 assert_eq!(cands, vec!["datasets ".to_string()]);
4372 }
4373
4374 #[test]
4375 fn trace_flag_carries_trailing_space() {
4376 let tree = vd_like_tree();
4377 let cands = trace_completion_candidates("vd", &tree, "run --st", None);
4378 assert_eq!(cands, vec!["--strict ".to_string()]);
4379 }
4380
4381 #[test]
4382 fn trace_key_eq_candidate_stays_open() {
4383 // `cycles=` awaits its value — the cursor must stay glued
4384 // to the `=`, so no trailing space.
4385 let tree = vd_like_tree();
4386 let cands = trace_completion_candidates("vd", &tree, "run cy", None);
4387 assert_eq!(cands, vec!["cycles=".to_string()]);
4388 }
4389
4390 #[test]
4391 fn trace_sibling_prefix_candidates_all_carry_spaces() {
4392 // Multiple matches: readline inserts the longest common
4393 // prefix of COMPREPLY (`list `/`fetch ` share none beyond
4394 // the menu), so trailing spaces on every entry are inert
4395 // for prefix insertion and correct on final acceptance.
4396 let tree = vd_like_tree();
4397 let cands = trace_completion_candidates("vd", &tree, "datasets ", None);
4398 assert_eq!(cands, vec!["fetch ".to_string(), "list ".to_string()]);
4399 }
4400
4401 #[test]
4402 fn trace_subtree_provider_output_is_verbatim() {
4403 // Grammar providers own their spacing: mid-context inserts
4404 // like `delta(` must pass through with no space appended.
4405 let provider: SubtreeProvider = std::sync::Arc::new(|pp: &PartialParse| {
4406 vec![format!("{}delta(", pp.partial)]
4407 });
4408 let tree = CommandTree::new("vd")
4409 .command("query", Node::leaf(&[]).with_subtree_provider(provider));
4410 let cands = trace_completion_candidates("vd", &tree, "query del", None);
4411 assert_eq!(cands, vec!["deldelta(".to_string()],
4412 "subtree-provider candidates are grammar-owned and must not be space-suffixed");
4413 }
4414
4415 #[test]
4416 fn shell_ready_keeps_open_endings_glued() {
4417 // Direct contract check on the finalization pass: `=` and
4418 // `/` endings stay open, complete words get the space.
4419 let tree = vd_like_tree();
4420 let out = shell_ready_candidates(
4421 &tree,
4422 &["vd", "run", ""],
4423 vec!["sub/".into(), "key=".into(), "word".into()],
4424 );
4425 assert_eq!(out, vec![
4426 "sub/".to_string(),
4427 "key=".to_string(),
4428 "word ".to_string(),
4429 ]);
4430 }
4431}