use std::collections::BTreeMap;
pub mod providers;
pub type ValueProvider = std::sync::Arc<dyn Fn(&str, &[&str]) -> Vec<String> + Send + Sync>;
pub fn fn_provider(f: fn(&str, &[&str]) -> Vec<String>) -> ValueProvider {
std::sync::Arc::new(f)
}
#[derive(Debug, Clone)]
pub enum ClosedValues {
Static(&'static [&'static str]),
Owned(Vec<String>),
}
impl ClosedValues {
pub fn values(&self) -> Vec<&str> {
match self {
ClosedValues::Static(s) => s.to_vec(),
ClosedValues::Owned(v) => v.iter().map(|s| s.as_str()).collect(),
}
}
pub fn complete(&self, partial: &str) -> Vec<String> {
match self {
ClosedValues::Static(s) => s
.iter()
.filter(|v| v.starts_with(partial))
.map(|v| (*v).to_string())
.collect(),
ClosedValues::Owned(v) => v
.iter()
.filter(|val| val.starts_with(partial))
.cloned()
.collect(),
}
}
pub fn validate(&self, value: &str) -> bool {
match self {
ClosedValues::Static(s) => s.iter().any(|v| *v == value),
ClosedValues::Owned(v) => v.iter().any(|val| val == value),
}
}
pub fn into_provider(self) -> ValueProvider {
std::sync::Arc::new(move |partial: &str, _ctx: &[&str]| self.complete(partial))
}
}
impl From<ClosedValues> for ValueProvider {
fn from(cv: ClosedValues) -> Self {
cv.into_provider()
}
}
pub trait LevelTag: 'static + Send + Sync + std::fmt::Debug {
fn rank(&self) -> u32;
fn name(&self) -> &'static str { "" }
}
pub trait CategoryTag: 'static + Send + Sync + std::fmt::Debug {
fn tag(&self) -> &'static str;
}
pub type DynamicOptionsProvider = fn(partial: &str, context: &[&str]) -> Vec<String>;
pub const DEFAULT_LEVEL: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MetadataError {
MissingCategory { command: String },
MissingLevel { command: String },
}
impl std::fmt::Display for MetadataError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MetadataError::MissingCategory { command } =>
write!(f, "command '{command}' is missing a category — call \
Node::with_category(...) when registering"),
MetadataError::MissingLevel { command } =>
write!(f, "command '{command}' is missing an explicit level — \
call Node::with_level(N) when registering"),
}
}
}
impl std::error::Error for MetadataError {}
#[derive(Clone)]
pub struct Node {
category: Option<String>,
level: Option<u32>,
help: Option<String>,
children: BTreeMap<String, Node>,
flags: Vec<String>,
boolean_flags: std::collections::HashSet<String>,
flag_help: BTreeMap<String, String>,
flag_long_help: BTreeMap<String, String>,
value_providers: BTreeMap<String, ValueProvider>,
dynamic_options: Option<DynamicOptionsProvider>,
promoted_globals: Vec<(String, ValueProvider)>,
subtree_provider: Option<SubtreeProvider>,
extras: Option<Extras>,
}
pub type SubtreeProvider =
std::sync::Arc<dyn Fn(&PartialParse) -> Vec<String> + Send + Sync>;
#[derive(Clone)]
pub struct Extras(pub std::sync::Arc<dyn std::any::Any + Send + Sync>);
impl std::fmt::Debug for Extras {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Extras").field("type_id", &self.0.type_id()).finish()
}
}
impl Extras {
pub fn new<T: std::any::Any + Send + Sync + 'static>(value: T) -> Self {
Extras(std::sync::Arc::new(value))
}
pub fn downcast<T: std::any::Any + Send + Sync + 'static>(
&self,
) -> Option<&T> {
self.0.downcast_ref::<T>()
}
}
#[derive(Debug, Clone)]
pub struct PartialParse<'a> {
pub completed: Vec<&'a str>,
pub partial: &'a str,
pub tree_path: Vec<&'a str>,
pub raw_line: &'a str,
pub cursor_offset: usize,
pub tap_count: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct BracketState {
pub paren: i32,
pub brace: i32,
pub bracket: i32,
pub inside_quote: Option<char>,
}
impl<'a> PartialParse<'a> {
pub fn before_cursor(&self) -> &'a str {
if self.raw_line.is_empty() { return ""; }
&self.raw_line[..self.cursor_offset.min(self.raw_line.len())]
}
pub fn after_cursor(&self) -> &'a str {
if self.raw_line.is_empty() { return ""; }
&self.raw_line[self.cursor_offset.min(self.raw_line.len())..]
}
pub fn bracket_state(&self) -> BracketState {
let s = self.before_cursor();
let mut state = BracketState::default();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if let Some(q) = state.inside_quote {
if c == '\\' {
chars.next();
continue;
}
if c == q {
state.inside_quote = None;
}
continue;
}
match c {
'(' => state.paren += 1,
')' => state.paren -= 1,
'{' => state.brace += 1,
'}' => state.brace -= 1,
'[' => state.bracket += 1,
']' => state.bracket -= 1,
'"' | '\'' => state.inside_quote = Some(c),
_ => {}
}
}
state
}
pub fn trigger_char(&self) -> Option<char> {
let s = self.before_cursor();
let mut chars = s.chars().rev();
while let Some(c) = chars.clone().next() {
if is_ident_char(c) {
chars.next();
} else {
break;
}
}
chars.next()
}
pub const DEFAULT_BASH_WORDBREAKS: &'static str = " \t\n<>;|&";
pub fn shell_word_start(&self) -> usize {
let before = self.before_cursor();
before
.rfind(|c: char| Self::DEFAULT_BASH_WORDBREAKS.contains(c))
.map(|p| p + 1)
.unwrap_or(0)
}
pub fn shell_current_word(&self) -> &'a str {
&self.raw_line[self.shell_word_start().min(self.raw_line.len())
..self.cursor_offset.min(self.raw_line.len())]
}
pub fn splice_candidate(&self, target_start: usize, suggestion: &str) -> String {
let sws = self.shell_word_start();
if target_start <= sws {
suggestion.to_string()
} else {
let end = target_start.min(self.raw_line.len());
let prefix = &self.raw_line[sws..end];
format!("{prefix}{suggestion}")
}
}
pub fn ident_before_cursor(&self) -> &'a str {
let s = self.before_cursor();
let bytes = s.as_bytes();
let mut i = bytes.len();
while i > 0 {
let c = bytes[i - 1] as char;
if is_ident_char(c) {
i -= 1;
} else {
break;
}
}
&s[i..]
}
}
#[inline]
fn is_ident_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == ':'
}
impl std::fmt::Debug for Node {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Node")
.field("category", &self.category)
.field("level", &self.level)
.field("help", &self.help)
.field("children", &self.children)
.field("flags", &self.flags)
.field("boolean_flags", &self.boolean_flags)
.field("flag_help", &self.flag_help.keys().collect::<Vec<_>>())
.field("value_providers", &self.value_providers.keys().collect::<Vec<_>>())
.field("has_dynamic_options", &self.dynamic_options.is_some())
.field("promoted_globals", &self.promoted_globals.iter().map(|(k, _)| k).collect::<Vec<_>>())
.field("has_subtree_provider", &self.subtree_provider.is_some())
.field("has_extras", &self.extras.is_some())
.finish()
}
}
impl Default for Node {
fn default() -> Self {
Node {
category: None,
level: None,
help: None,
children: BTreeMap::new(),
flags: Vec::new(),
boolean_flags: std::collections::HashSet::new(),
flag_help: BTreeMap::new(),
flag_long_help: BTreeMap::new(),
value_providers: BTreeMap::new(),
dynamic_options: None,
promoted_globals: Vec::new(),
subtree_provider: None,
extras: None,
}
}
}
impl Node {
pub fn new() -> Self { Self::default() }
pub fn leaf(flags: &[&str]) -> Self {
Node {
flags: flags.iter().map(|s| s.to_string()).collect(),
..Self::default()
}
}
pub fn leaf_with_flags(value_flags: &[&str], boolean_flags: &[&str]) -> Self {
let all: Vec<String> = value_flags.iter()
.chain(boolean_flags.iter())
.map(|s| s.to_string())
.collect();
Node {
flags: all,
boolean_flags: boolean_flags.iter().map(|s| s.to_string()).collect(),
..Self::default()
}
}
pub fn group(children: Vec<(&str, Node)>) -> Self {
Node {
children: children.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect(),
..Self::default()
}
}
pub fn empty_group() -> Self { Self::default() }
pub fn is_leaf(&self) -> bool { self.children.is_empty() }
pub fn is_group(&self) -> bool { !self.children.is_empty() }
pub fn with_child(mut self, name: &str, child: Node) -> Self {
self.children.insert(name.to_string(), child);
self
}
pub fn children(&self) -> &BTreeMap<String, Node> { &self.children }
pub fn children_mut(&mut self) -> &mut BTreeMap<String, Node> { &mut self.children }
pub fn child_names(&self) -> Vec<&str> {
self.children.keys().map(|k| k.as_str()).collect()
}
pub fn child(&self, name: &str) -> Option<&Node> {
self.children.get(name)
}
pub fn with_flags(mut self, flags: &[&str]) -> Self {
for f in flags {
if !self.flags.iter().any(|x| x == f) {
self.flags.push((*f).to_string());
}
}
self
}
pub fn with_boolean_flags(mut self, flags: &[&str]) -> Self {
for f in flags {
if !self.flags.iter().any(|x| x == f) {
self.flags.push((*f).to_string());
}
self.boolean_flags.insert((*f).to_string());
}
self
}
pub fn flags(&self) -> &[String] { &self.flags }
pub fn is_flag(&self, flag: &str) -> bool {
self.boolean_flags.contains(flag)
}
pub fn options(&self) -> Vec<&str> {
self.flags.iter().map(|s| s.as_str()).collect()
}
pub fn with_value_provider(mut self, flag: &str, provider: ValueProvider) -> Self {
self.value_providers.insert(flag.to_string(), provider);
self
}
pub fn with_value_provider_aliases(
mut self,
aliases: &[&str],
provider: ValueProvider,
) -> Self {
for name in aliases {
self.value_providers.insert((*name).to_string(), provider.clone());
}
self
}
pub fn value_providers(&self) -> &BTreeMap<String, ValueProvider> {
&self.value_providers
}
pub fn with_dynamic_options(mut self, provider: DynamicOptionsProvider) -> Self {
self.dynamic_options = Some(provider);
self
}
pub fn dynamic_options(&self) -> Option<DynamicOptionsProvider> {
self.dynamic_options
}
pub fn with_category(mut self, cat: &str) -> Self {
self.category = Some(cat.to_string());
self
}
pub fn category(&self) -> Option<&str> { self.category.as_deref() }
pub fn with_level(mut self, lvl: u32) -> Self {
self.level = Some(lvl);
self
}
pub fn level(&self) -> u32 {
self.level.unwrap_or(DEFAULT_LEVEL)
}
pub fn level_explicit(&self) -> Option<u32> { self.level }
pub fn with_help(mut self, text: &str) -> Self {
self.help = Some(text.to_string());
self
}
pub fn help(&self) -> Option<&str> { self.help.as_deref() }
pub fn with_flag_help(mut self, flag: &str, help: &str) -> Self {
self.flag_help.insert(flag.to_string(), help.to_string());
self
}
pub fn with_flag_long_help(mut self, flag: &str, help: &str) -> Self {
self.flag_long_help.insert(flag.to_string(), help.to_string());
self
}
pub fn flag_long_help_for(&self, flag: &str) -> Option<&str> {
self.flag_long_help.get(flag).map(|s| s.as_str())
}
pub fn flag_help_for(&self, flag: &str) -> Option<&str> {
self.flag_help.get(flag).map(|s| s.as_str())
}
pub fn with_promoted_global(mut self, token: &str, provider: ValueProvider) -> Self {
self.promoted_globals.push((token.to_string(), provider));
self
}
fn drain_promoted_globals_into(
&mut self,
out: &mut std::collections::BTreeMap<String, ValueProvider>,
) {
for (token, provider) in std::mem::take(&mut self.promoted_globals) {
out.insert(token, provider);
}
for child in self.children.values_mut() {
child.drain_promoted_globals_into(out);
}
}
pub fn with_subtree_provider(mut self, provider: SubtreeProvider) -> Self {
self.subtree_provider = Some(provider);
self
}
pub fn subtree_provider(&self) -> Option<&SubtreeProvider> {
self.subtree_provider.as_ref()
}
pub fn with_extras(mut self, extras: Extras) -> Self {
self.extras = Some(extras);
self
}
pub fn extras(&self) -> Option<&Extras> { self.extras.as_ref() }
}
pub fn render_usage(node: &Node, path: &[&str]) -> String {
let mut out = String::new();
out.push_str(&format!("USAGE: {}\n", path.join(" ")));
if let Some(help) = node.help() {
out.push('\n');
out.push_str(help);
out.push('\n');
}
if !node.flags.is_empty() {
out.push_str("\nFLAGS:\n");
let width = node.flags.iter().map(|f| f.len()).max().unwrap_or(0);
for f in &node.flags {
let h = node.flag_help_for(f).unwrap_or("");
out.push_str(&format!(" {:width$} {}\n", f, h, width = width));
}
}
if !node.children.is_empty() {
out.push_str("\nSUBCOMMANDS:\n");
let width = node.children.keys().map(|k| k.len()).max().unwrap_or(0);
for (name, child) in &node.children {
let h = child.help().unwrap_or("");
out.push_str(&format!(" {:width$} {}\n", name, h, width = width));
}
}
out
}
pub struct StrictNode<const HAS_CATEGORY: bool, const HAS_LEVEL: bool> {
inner: Node,
}
impl StrictNode<false, false> {
pub fn leaf(options: &[&str]) -> Self {
Self { inner: Node::leaf(options) }
}
pub fn leaf_with_flags(options: &[&str], flags: &[&str]) -> Self {
Self { inner: Node::leaf_with_flags(options, flags) }
}
pub fn group(children: Vec<(&str, Node)>) -> Self {
Self { inner: Node::group(children) }
}
pub fn from_node(node: Node) -> Self {
Self { inner: node }
}
}
impl<const C: bool, const L: bool> StrictNode<C, L> {
pub fn with_category(self, cat: &str) -> StrictNode<true, L> {
StrictNode { inner: self.inner.with_category(cat) }
}
pub fn with_level(self, lvl: u32) -> StrictNode<C, true> {
StrictNode { inner: self.inner.with_level(lvl) }
}
pub fn with_value_provider(mut self, option: &str, provider: ValueProvider) -> Self {
self.inner = self.inner.with_value_provider(option, provider);
self
}
pub fn with_dynamic_options(mut self, provider: DynamicOptionsProvider) -> Self {
self.inner = self.inner.with_dynamic_options(provider);
self
}
}
impl StrictNode<true, true> {
pub fn into_node(self) -> Node { self.inner }
}
pub struct CommandTree {
pub app_name: String,
pub root: Node,
pub hidden: std::collections::HashSet<String>,
pub global_value_providers: BTreeMap<String, ValueProvider>,
pub global_flag_help: BTreeMap<String, String>,
pub global_flag_long_help: BTreeMap<String, String>,
pub strict_metadata: bool,
}
impl CommandTree {
pub fn max_level(&self) -> u32 {
let mut max = DEFAULT_LEVEL;
for child in self.root.children.values() {
if child.level() > max {
max = child.level();
}
}
max
}
pub fn new(app_name: &str) -> Self {
CommandTree {
app_name: app_name.to_string(),
root: Node::empty_group(),
hidden: std::collections::HashSet::new(),
global_value_providers: BTreeMap::new(),
global_flag_help: BTreeMap::new(),
global_flag_long_help: BTreeMap::new(),
strict_metadata: false,
}
}
pub fn global_flag_help_for(&self, flag: &str) -> Option<&str> {
self.global_flag_help.get(flag).map(|s| s.as_str())
}
pub fn global_flag_help(mut self, flag: &str, help: &str) -> Self {
self.global_flag_help.insert(flag.to_string(), help.to_string());
self
}
pub fn global_flag_long_help_for(&self, flag: &str) -> Option<&str> {
self.global_flag_long_help.get(flag).map(|s| s.as_str())
}
pub fn global_flag_long_help(mut self, flag: &str, help: &str) -> Self {
self.global_flag_long_help.insert(flag.to_string(), help.to_string());
self
}
pub fn require_metadata(mut self) -> Self {
self.strict_metadata = true;
self
}
pub fn validate(&self) -> Result<(), Vec<MetadataError>> {
let mut errors = Vec::new();
for (name, node) in &self.root.children {
if node.category().is_none() {
errors.push(MetadataError::MissingCategory {
command: name.clone(),
});
}
if node.level_explicit().is_none() {
errors.push(MetadataError::MissingLevel {
command: name.clone(),
});
}
}
if errors.is_empty() { Ok(()) } else { Err(errors) }
}
fn check_strict(&self, name: &str, node: &Node) {
if !self.strict_metadata { return; }
if node.category().is_none() {
panic!("veks-completion: app '{}' has require_metadata() set, \
but command '{name}' was registered without \
Node::with_category(...). Add a category tag.",
self.app_name);
}
if node.level_explicit().is_none() {
panic!("veks-completion: app '{}' has require_metadata() set, \
but command '{name}' was registered without \
Node::with_level(...). Pick a tap-tier level (1, 2, 3, ...).",
self.app_name);
}
}
pub fn command(mut self, name: &str, node: Node) -> Self {
self.check_strict(name, &node);
self.root = self.root.with_child(name, node);
self
}
pub fn strict_command(
mut self,
name: &str,
node: StrictNode<true, true>,
) -> Self {
self.root = self.root.with_child(name, node.into_node());
self
}
pub fn strict_group(self, name: &str, node: StrictNode<true, true>) -> Self {
self.strict_command(name, node)
}
pub fn strict_hidden_command(
mut self,
name: &str,
node: StrictNode<true, true>,
) -> Self {
self.hidden.insert(name.to_string());
self.root = self.root.with_child(name, node.into_node());
self
}
pub fn group(self, name: &str, node: Node) -> Self {
self.command(name, node)
}
pub fn hidden_command(mut self, name: &str, node: Node) -> Self {
self.check_strict(name, &node);
self.hidden.insert(name.to_string());
self.command(name, node)
}
pub fn global_value_provider(mut self, option: &str, provider: ValueProvider) -> Self {
self.global_value_providers.insert(option.to_string(), provider);
self
}
pub fn lift_promoted_globals(mut self) -> Self {
self.root.drain_promoted_globals_into(&mut self.global_value_providers);
self
}
pub fn with_auto_help(mut self) -> Self {
attach_auto_help(&mut self.root);
self
}
pub fn with_metricsql_at(
mut self,
path: &[&str],
catalog: std::sync::Arc<dyn crate::providers::MetricsqlCatalog>,
) -> Self {
if let Some(node) = walk_path_mut(&mut self.root, path) {
*node = std::mem::take(node)
.with_subtree_provider(crate::providers::metricsql_provider(catalog));
}
self
}
}
fn attach_auto_help(node: &mut Node) {
if !node.flags.iter().any(|f| f == "--help") {
node.flags.push("--help".to_string());
node.boolean_flags.insert("--help".to_string());
if !node.flag_help.contains_key("--help") {
node.flag_help.insert("--help".to_string(),
"Show usage information for this command.".to_string());
}
}
for child in node.children.values_mut() {
attach_auto_help(child);
}
}
fn walk_path_mut<'a>(root: &'a mut Node, path: &[&str]) -> Option<&'a mut Node> {
let mut node = root;
for segment in path {
node = node.children.get_mut(*segment)?;
}
Some(node)
}
fn word_matches_option(word: &str, option: &str) -> bool {
if word == option { return true; }
if let Some(key) = option.strip_suffix('=') {
if word.starts_with(key) && word[key.len()..].starts_with('=') {
return true;
}
let dashed = format!("--{key}");
if word.starts_with(&dashed) && word[dashed.len()..].starts_with('=') {
return true;
}
}
if option.starts_with("--") && !option.ends_with('=') {
if word.starts_with(option) && word[option.len()..].starts_with('=') {
return true;
}
let bare = &option[2..];
if word.starts_with(bare) && word[bare.len()..].starts_with('=') {
return true;
}
}
false
}
fn consumed_keys(words: &[&str], options: &[String]) -> std::collections::HashSet<String> {
let mut consumed = std::collections::HashSet::new();
for &word in words {
for opt in options {
if word_matches_option(word, opt) {
let key = opt.trim_start_matches('-').trim_end_matches('=');
consumed.insert(key.to_string());
}
}
}
consumed
}
fn is_consumed(option: &str, consumed: &std::collections::HashSet<String>) -> bool {
let key = option.trim_start_matches('-').trim_end_matches('=');
consumed.contains(key)
}
pub fn complete(tree: &CommandTree, words: &[&str]) -> Vec<String> {
complete_at_tap(tree, words, 1)
}
pub fn complete_at_level_only(tree: &CommandTree, words: &[&str], only_level: u32) -> Vec<String> {
let partial = if words.len() > 1 { *words.last().unwrap_or(&"") } else { "" };
let completed: &[&str] = if words.len() > 1 { &words[1..words.len() - 1] } else { &[] };
if !partial.is_empty() {
return complete(tree, words);
}
let mut node = &tree.root;
let at_root = completed.is_empty();
for &word in completed {
match node.child(word) {
Some(child) => node = child,
None => return complete(tree, words),
}
}
if !node.children.is_empty() {
let mut candidates: Vec<(u32, String)> = node.children.iter()
.filter(|(k, _)| !at_root || !tree.hidden.contains(k.as_str()))
.filter(|(_, child)| child.level() <= only_level)
.map(|(k, child)| (child.level(), k.to_string()))
.collect();
candidates.sort_by(|(la, a), (lb, b)| {
la.cmp(lb)
.then_with(|| a.starts_with('-').cmp(&b.starts_with('-')))
.then_with(|| a.cmp(b))
});
return candidates.into_iter().map(|(_, k)| k).collect();
}
complete(tree, words)
}
pub fn complete_rotating(tree: &CommandTree, words: &[&str], tap_count: u32) -> Vec<String> {
complete_rotating_with_raw(tree, words, tap_count, "", 0)
}
pub fn complete_rotating_with_raw(
tree: &CommandTree,
words: &[&str],
tap_count: u32,
raw_line: &str,
cursor_offset: usize,
) -> Vec<String> {
let completed: &[&str] = if words.len() > 1 { &words[1..words.len() - 1] } else { &[] };
let partial: &str = if words.len() > 1 { *words.last().unwrap_or(&"") } else { "" };
let mut node = &tree.root;
for &word in completed {
match node.child(word) {
Some(child) => node = child,
None => break,
}
}
let mut subtree_node = &tree.root;
let mut has_subtree = subtree_node.subtree_provider().is_some();
for &word in completed {
if let Some(child) = subtree_node.child(word) {
subtree_node = child;
if subtree_node.subtree_provider().is_some() {
has_subtree = true;
}
} else { break; }
}
if has_subtree {
return complete_at_tap_with_raw(tree, words, tap_count, raw_line, cursor_offset);
}
let max = max_level_of_children(node).max(1);
let only = ((tap_count.saturating_sub(1)) % max) + 1;
let prev_word_opt = completed.last().copied();
let at_value_position = prev_word_opt
.map(|w| {
w.starts_with("--")
&& !w.contains('=')
&& !node.boolean_flags.contains(w)
&& (node.value_providers.contains_key(w)
|| tree.global_value_providers.contains_key(w)
|| node.flag_help_for(w).is_some())
})
.unwrap_or(false);
let effective_tap = if at_value_position { tap_count } else { only };
if partial.is_empty() && !at_value_position {
return complete_at_level_only(tree, words, effective_tap);
}
complete_at_tap_with_raw(tree, words, effective_tap, raw_line, cursor_offset)
}
pub(crate) fn max_level_of_children(node: &Node) -> u32 {
let mut max = DEFAULT_LEVEL;
for child in node.children.values() {
if child.level() > max {
max = child.level();
}
}
max
}
pub fn complete_at_tap(tree: &CommandTree, words: &[&str], tap_count: u32) -> Vec<String> {
complete_at_tap_with_raw(tree, words, tap_count, "", 0)
}
pub fn complete_at_tap_with_raw(
tree: &CommandTree,
words: &[&str],
tap_count: u32,
raw_line: &str,
cursor_offset: usize,
) -> Vec<String> {
if words.len() <= 1 {
let mut cmds: Vec<String> = tree.root.child_names().iter()
.filter(|s| !tree.hidden.contains(**s))
.filter(|s| {
tree.root.child(s)
.map(|n| n.level() <= tap_count)
.unwrap_or(true)
})
.map(|s| s.to_string())
.collect();
cmds.sort_by(|a, b| {
a.starts_with('-').cmp(&b.starts_with('-')).then_with(|| a.cmp(b))
});
return cmds;
}
let partial = words.last().unwrap_or(&"");
let completed = &words[1..words.len() - 1];
let at_root = completed.is_empty();
let mut node = &tree.root;
let mut remaining_start = 0;
let mut tree_path: Vec<&str> = Vec::new();
let mut deepest_subtree: Option<&SubtreeProvider> = node.subtree_provider();
for (i, &word) in completed.iter().enumerate() {
match node.child(word) {
Some(child) => {
node = child;
remaining_start = i + 1;
tree_path.push(word);
if let Some(p) = node.subtree_provider() {
deepest_subtree = Some(p);
}
}
None => break,
}
}
let remaining = &completed[remaining_start..];
if let Some(provider) = deepest_subtree {
let pp = PartialParse {
completed: completed.to_vec(),
partial,
tree_path,
raw_line,
cursor_offset,
tap_count,
};
return provider(&pp);
}
if let Some(&prev_word) = completed.last()
&& let Some(provider) = tree.global_value_providers.get(prev_word) {
return provider(partial, remaining);
}
if let Some(&prev_word) = remaining.last()
&& prev_word.starts_with("--")
&& !prev_word.contains('=')
&& !node.boolean_flags.contains(prev_word)
{
emit_value_position_help(tree, node, prev_word, tap_count);
if let Some(provider) = node.value_providers.get(prev_word) {
return provider(partial, remaining);
}
if let Some(provider) = tree.global_value_providers.get(prev_word) {
return provider(partial, remaining);
}
return Vec::new();
}
if let Some(eq_pos) = partial.find('=') {
let key = &partial[..eq_pos];
let value_partial = &partial[eq_pos + 1..];
let key_eq = format!("{key}=");
let dashed_key = format!("--{key}");
if let Some(provider) = node.value_providers.get(&key_eq)
.or_else(|| node.value_providers.get(&dashed_key))
.or_else(|| tree.global_value_providers.get(&key_eq))
.or_else(|| tree.global_value_providers.get(&dashed_key))
{
return provider(value_partial, remaining);
}
return Vec::new();
}
let mut child_candidates: Vec<(u32, String)> = node.children.iter()
.filter(|(k, _)| k.starts_with(partial))
.filter(|(k, _)| !at_root || !partial.is_empty() || !tree.hidden.contains(k.as_str()))
.filter(|(_, child)| {
!at_root || !partial.is_empty() || child.level() <= tap_count
})
.map(|(k, child)| (child.level(), k.to_string()))
.collect();
child_candidates.sort_by(|(la, a), (lb, b)| {
la.cmp(lb)
.then_with(|| a.starts_with('-').cmp(&b.starts_with('-')))
.then_with(|| a.cmp(b))
});
let mut flag_candidates: Vec<String> = Vec::new();
if !node.flags.is_empty() || node.dynamic_options.is_some() {
let mut all_flags: Vec<String> = node.flags.clone();
if let Some(provider) = node.dynamic_options {
for opt in provider(partial, remaining) {
if !all_flags.contains(&opt) {
all_flags.push(opt);
}
}
}
let consumed = consumed_keys(remaining, &all_flags);
for f in &all_flags {
if f.starts_with(partial) && !is_consumed(f, &consumed) {
flag_candidates.push(f.clone());
}
}
}
for global_opt in tree.global_value_providers.keys() {
if global_opt.starts_with(partial) && !flag_candidates.contains(global_opt) {
flag_candidates.push(global_opt.clone());
}
}
flag_candidates.sort_by(|a, b| {
a.starts_with('-').cmp(&b.starts_with('-')).then_with(|| a.cmp(b))
});
let mut out: Vec<String> = child_candidates.into_iter().map(|(_, k)| k).collect();
out.extend(flag_candidates);
out
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Shell {
Bash,
Zsh,
Fish,
Elvish,
PowerShell,
}
impl Shell {
pub fn from_name(name: &str) -> Option<Self> {
match name {
"bash" => Some(Self::Bash),
"zsh" => Some(Self::Zsh),
"fish" => Some(Self::Fish),
"elvish" => Some(Self::Elvish),
"pwsh" | "powershell" => Some(Self::PowerShell),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Self::Bash => "bash",
Self::Zsh => "zsh",
Self::Fish => "fish",
Self::Elvish => "elvish",
Self::PowerShell => "powershell",
}
}
}
pub fn detect_shell() -> Option<Shell> {
if let Ok(shell_path) = std::env::var("SHELL") {
let name = std::path::Path::new(&shell_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if let Some(s) = Shell::from_name(name) {
return Some(s);
}
}
#[cfg(target_os = "linux")]
{
if let Ok(status) = std::fs::read_to_string("/proc/self/status") {
if let Some(ppid_line) = status.lines().find(|l| l.starts_with("PPid:")) {
if let Some(ppid) = ppid_line.split_whitespace().nth(1) {
if let Ok(comm) = std::fs::read_to_string(format!("/proc/{}/comm", ppid)) {
let name = comm.trim();
if let Some(s) = Shell::from_name(name) {
return Some(s);
}
}
}
}
}
}
None
}
pub fn print_indirect_wrapper(app_name: &str) {
let app_path = std::env::args_os()
.next()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| app_name.to_string());
match detect_shell() {
Some(Shell::Bash) => {
println!("# {} tab-completion for bash", app_name);
println!("# To activate: eval \"$({} completions)\"", app_name);
println!("# To persist: echo 'eval \"$({} completions)\"' >> ~/.bashrc", app_name);
println!("source <(\"{}\" completions --shell bash)", app_path);
}
Some(Shell::Zsh) => {
println!("# {} tab-completion for zsh", app_name);
println!("# To activate: eval \"$({} completions)\"", app_name);
println!("# To persist: echo 'eval \"$({} completions)\"' >> ~/.zshrc", app_name);
println!("source <(\"{}\" completions --shell zsh)", app_path);
}
Some(Shell::Fish) => {
println!("# {} tab-completion for fish", app_name);
println!("# To activate: eval ({} completions)", app_name);
println!("# To persist: add to ~/.config/fish/config.fish");
println!("\"{}\" completions --shell fish | source", app_path);
}
Some(other) => {
print_completions(app_name, other);
}
None => {
println!("# {0}: could not detect your shell.", app_name);
println!("# Use: eval \"$({0} completions --shell bash)\"", app_name);
}
}
}
pub fn print_completions(app_name: &str, shell: Shell) {
match shell {
Shell::Bash => print_bash_script(app_name),
Shell::Zsh => {
eprintln!("# zsh completions: using bash-compatible mode");
print_bash_script(app_name);
}
Shell::Fish | Shell::Elvish | Shell::PowerShell => {
eprintln!(
"# {} completions for `{}` are not yet implemented",
shell.name(), app_name,
);
}
}
}
pub fn print_bash_script(app_name: &str) {
let env_var = format!("_{}_COMPLETE", app_name.to_uppercase().replace('-', "_"));
let completer = std::env::args_os()
.next()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| app_name.to_string());
print!(r#"_{app}_complete() {{ local IFS=$'\n'; local COMP_WORDBREAKS=$' \t\n<>;|&'; COMPREPLY=($({env_var}=bash "{completer}" "$COMP_LINE" "$COMP_POINT")); }}
complete -o nosort -o nospace -F _{app}_complete {app}
"#,
app = app_name,
env_var = env_var,
completer = completer,
);
}
fn split_line(line: &str, point: usize) -> (Vec<String>, String) {
let point = point.min(line.len());
let head = &line[..point];
let mut words: Vec<String> = Vec::new();
let mut cur = String::new();
let mut in_quote: Option<char> = None;
let mut chars = head.chars().peekable();
while let Some(ch) = chars.next() {
match in_quote {
Some(q) if ch == q => { in_quote = None; }
Some(_) => cur.push(ch),
None => match ch {
'\'' | '"' => { in_quote = Some(ch); }
'\\' => {
if let Some(next) = chars.next() { cur.push(next); }
}
' ' | '\t' => {
if !cur.is_empty() {
words.push(std::mem::take(&mut cur));
}
}
_ => cur.push(ch),
}
}
}
if !words.is_empty() { words.remove(0); }
(words, cur)
}
pub const DIAGNOSTIC_FLAGS: &[&str] = &[
"---help", "---version", "---dump-tree", "---list-providers", "---validate", "---trace-completion", "---trace-partial-parse", ];
pub fn handle_diagnostic_args(app_name: &str, tree: &CommandTree) -> bool {
let argv: Vec<String> = std::env::args().collect();
let flag_idx = argv.iter().position(|a| a.starts_with("---"));
let Some(idx) = flag_idx else { return false; };
let flag = argv[idx].as_str();
let rest: Vec<&str> = argv.iter().skip(idx + 1).map(|s| s.as_str()).collect();
match flag {
"---help" => {
println!("veks-completion diagnostic flags (triple-dash, reserved):");
for f in DIAGNOSTIC_FLAGS {
println!(" {f}");
}
println!();
println!("These are dev / test-only. They never collide with normal");
println!("`--` CLI flags. See the `handle_diagnostic_args` rustdoc.");
}
"---version" => {
println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
let _ = app_name;
}
"---dump-tree" => dump_tree(&tree.root, &mut Vec::new()),
"---list-providers" => list_providers(&tree.root, &mut Vec::new()),
"---validate" => match tree.validate() {
Ok(()) => println!("ok"),
Err(errors) => {
for e in errors {
println!("{:?}", e);
}
std::process::exit(1);
}
},
"---trace-completion" => {
let (line_with_app, point_in_line) = synth_line_for_trace(app_name, &rest);
let (prior, cur) = split_line(&line_with_app, point_in_line);
let mut words_owned: Vec<String> = vec![app_name.to_string()];
words_owned.extend(prior);
words_owned.push(cur);
let words: Vec<&str> = words_owned.iter().map(|s| s.as_str()).collect();
let cands = complete_at_tap_with_raw(tree, &words, 1, &line_with_app, point_in_line);
for c in cands {
println!("{c}");
}
}
"---trace-partial-parse" => {
let (line_with_app, point_in_line) = synth_line_for_trace(app_name, &rest);
let (prior, cur) = split_line(&line_with_app, point_in_line);
let prior_owned: Vec<String> = prior;
let cur_owned = cur;
let pp = PartialParse {
completed: prior_owned.iter().map(|s| s.as_str()).collect(),
partial: &cur_owned,
tree_path: Vec::new(),
raw_line: &line_with_app,
cursor_offset: point_in_line,
tap_count: 1,
};
print_partial_parse(&pp);
}
other if other.starts_with("---") => {
return false;
}
_ => return false,
}
true
}
fn synth_line_for_trace(app_name: &str, rest: &[&str]) -> (String, usize) {
let user_line = rest.first().copied().unwrap_or("");
let user_point: usize = rest.get(1)
.and_then(|s| s.parse().ok())
.unwrap_or(user_line.len());
let prefix_len = app_name.len() + 1; (
format!("{} {}", app_name, user_line),
user_point + prefix_len,
)
}
pub fn print_partial_parse_for_diagnostics(pp: &PartialParse) {
print_partial_parse(pp);
}
fn emit_value_position_help(
tree: &CommandTree,
node: &Node,
prev_word: &str,
tap_count: u32,
) {
let (label, body): (&str, &str) = match tap_count {
2 => match node
.flag_help_for(prev_word)
.or_else(|| tree.global_flag_help_for(prev_word))
{
Some(h) => ("help", h),
None => return,
},
3 => {
let extended = node
.flag_long_help_for(prev_word)
.or_else(|| tree.global_flag_long_help_for(prev_word));
match extended {
Some(h) => ("detail", h),
None => match node
.flag_help_for(prev_word)
.or_else(|| tree.global_flag_help_for(prev_word))
{
Some(h) => ("help", h),
None => return,
},
}
}
_ => return,
};
eprintln!();
eprintln!("# {prev_word} ({label}):");
for line in body.lines() {
if line.is_empty() {
eprintln!("#");
} else {
eprintln!("# {line}");
}
}
eprintln!("#");
eprintln!("# use ctrl-l to clear help and restore the command line view");
}
fn dump_tree(node: &Node, path: &mut Vec<String>) {
let path_str = if path.is_empty() { "/".to_string() } else { format!("/{}", path.join("/")) };
let category = node.category().unwrap_or("");
let level = node.level();
let help_keys: Vec<&String> = node.flag_help.keys().collect();
let provider_keys: Vec<&String> = node.value_providers.keys().collect();
println!("{path_str} level={level} category={category} flags={:?} flag_help={:?} value_providers={:?} has_subtree_provider={} has_extras={}",
node.flags(),
help_keys,
provider_keys,
node.subtree_provider().is_some(),
node.extras().is_some());
for (name, child) in node.children() {
path.push(name.clone());
dump_tree(child, path);
path.pop();
}
}
fn list_providers(node: &Node, path: &mut Vec<String>) {
if node.subtree_provider().is_some() {
let p = if path.is_empty() { "/".to_string() } else { format!("/{}", path.join("/")) };
println!("{p}");
}
for (name, child) in node.children() {
path.push(name.clone());
list_providers(child, path);
path.pop();
}
}
fn print_partial_parse(pp: &PartialParse) {
println!("raw_line: {:?}", pp.raw_line);
println!("cursor_offset: {}", pp.cursor_offset);
println!("completed: {:?}", pp.completed);
println!("partial: {:?}", pp.partial);
println!("tree_path: {:?}", pp.tree_path);
println!("before_cursor(): {:?}", pp.before_cursor());
println!("after_cursor(): {:?}", pp.after_cursor());
println!("ident_before_cursor(): {:?}", pp.ident_before_cursor());
println!("trigger_char(): {:?}", pp.trigger_char());
let bs = pp.bracket_state();
println!("bracket_state: paren={} brace={} bracket={} inside_quote={:?}",
bs.paren, bs.brace, bs.bracket, bs.inside_quote);
println!("shell_word_start(): {}", pp.shell_word_start());
println!("shell_current_word(): {:?}", pp.shell_current_word());
}
pub fn handle_complete_env(app_name: &str, tree: &CommandTree) -> bool {
let env_var = format!("_{}_COMPLETE", app_name.to_uppercase().replace('-', "_"));
let is_ours = std::env::var(&env_var).ok().as_deref() == Some("bash");
let is_legacy = std::env::var("COMPLETE").ok().as_deref() == Some("bash");
if !is_ours && !is_legacy {
return false;
}
let argv: Vec<String> = std::env::args().collect();
let line = argv.get(1).cloned().unwrap_or_default();
let point: usize = argv.get(2)
.and_then(|s| s.parse().ok())
.unwrap_or(line.len());
let (prior, cur) = split_line(&line, point);
let mut words_owned: Vec<String> = vec![app_name.to_string()];
words_owned.extend(prior);
words_owned.push(cur);
let words: Vec<&str> = words_owned.iter().map(|s| s.as_str()).collect();
let input_key = words[1..].join(" ");
let completed_for_max: &[&str] = if words.len() > 1 {
&words[1..words.len() - 1]
} else {
&[]
};
let mut max_node = &tree.root;
let mut path_has_subtree_provider = max_node.subtree_provider().is_some();
for &word in completed_for_max {
if let Some(child) = max_node.child(word) {
max_node = child;
if max_node.subtree_provider().is_some() {
path_has_subtree_provider = true;
}
} else {
break;
}
}
let at_value_position = words
.iter()
.rev()
.skip(1) .next()
.map(|w| {
w.starts_with("--")
&& !w.contains('=')
&& !max_node.boolean_flags.contains(*w)
&& max_node.flag_help_for(w).is_some()
})
.unwrap_or(false);
let max_level = if path_has_subtree_provider {
SUBTREE_PROVIDER_MAX_TAPS
} else if at_value_position {
max_level_of_children(max_node).max(1).max(3)
} else {
max_level_of_children(max_node).max(1)
};
let tap_count = tap_detect(app_name, &input_key, max_level);
let candidates = complete_rotating_with_raw(tree, &words, tap_count, &line, point);
for candidate in candidates {
println!("{}", candidate);
}
true
}
pub const TAP_ADVANCE_MS: u128 = 200;
pub const SUBTREE_PROVIDER_MAX_TAPS: u32 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TapState {
pub time_ms: u128,
pub count: u32,
}
pub fn next_tap_state(
prev: Option<(TapState, &str)>,
now_ms: u128,
cur_key: &str,
max_level: u32,
) -> (u32, TapState) {
let max = max_level.max(1);
let mut tap_count = 1u32;
if let Some((prev_state, prev_key)) = prev {
if prev_key == cur_key
&& now_ms.saturating_sub(prev_state.time_ms) < TAP_ADVANCE_MS
{
tap_count = prev_state.count.saturating_add(1).min(max);
}
}
let to_persist = if tap_count >= max { 0 } else { tap_count };
let next = TapState {
time_ms: now_ms,
count: to_persist,
};
(tap_count, next)
}
fn parent_process_id() -> Option<i64> {
#[cfg(unix)]
{
unsafe extern "C" {
fn getppid() -> i32;
}
let pid = unsafe { getppid() };
Some(pid as i64)
}
#[cfg(not(unix))]
{
None
}
}
fn tap_detect(app_name: &str, input_key: &str, max_level: u32) -> u32 {
use std::io::Write;
let ppid: String = parent_process_id()
.map(|p| p.to_string())
.or_else(|| std::env::var("_COMP_SHELL_PID").ok())
.or_else(|| std::env::var("PPID").ok())
.unwrap_or_else(|| "0".to_string());
let tap_file = std::path::PathBuf::from(format!("/tmp/.{}_tap_{}", app_name, ppid));
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let cur_key = input_key.trim_end();
let prev_owned: Option<(TapState, String)> = std::fs::read_to_string(&tap_file)
.ok()
.and_then(|content| {
let mut parts = content.splitn(3, ' ');
let time_ms: u128 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let count: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let key = parts.next().unwrap_or("").trim_end().to_string();
Some((TapState { time_ms, count }, key))
});
let prev = prev_owned.as_ref().map(|(s, k)| (*s, k.as_str()));
let (tap_count, next) = next_tap_state(prev, now_ms, cur_key, max_level);
if let Ok(mut f) = std::fs::File::create(&tap_file) {
let _ = write!(f, "{} {} {}", next.time_ms, next.count, cur_key);
}
tap_count
}
#[derive(Debug, Clone)]
pub struct Directive {
pub cli_flag: &'static str,
pub help: Option<&'static str>,
pub values: Option<ClosedValues>,
pub boolean: bool,
pub repeatable: bool,
pub yaml_directive: Option<&'static str>,
}
impl Directive {
pub const fn closed(
cli_flag: &'static str,
values: &'static [&'static str],
) -> Self {
Directive {
cli_flag,
help: None,
values: Some(ClosedValues::Static(values)),
boolean: false,
repeatable: false,
yaml_directive: None,
}
}
pub const fn value(cli_flag: &'static str) -> Self {
Directive {
cli_flag,
help: None,
values: None,
boolean: false,
repeatable: false,
yaml_directive: None,
}
}
pub const fn boolean(cli_flag: &'static str) -> Self {
Directive {
cli_flag,
help: None,
values: None,
boolean: true,
repeatable: false,
yaml_directive: None,
}
}
pub const fn with_help(mut self, help: &'static str) -> Self {
self.help = Some(help);
self
}
pub const fn repeatable(mut self) -> Self {
self.repeatable = true;
self
}
pub const fn with_yaml(mut self, name: &'static str) -> Self {
self.yaml_directive = Some(name);
self
}
}
pub fn apply_directives(mut node: Node, directives: &[Directive]) -> Node {
let value_flags: Vec<&str> = directives.iter()
.filter(|d| !d.boolean)
.map(|d| d.cli_flag)
.collect();
let bool_flags: Vec<&str> = directives.iter()
.filter(|d| d.boolean)
.map(|d| d.cli_flag)
.collect();
let value_refs: Vec<&str> = value_flags.iter().copied().collect();
let bool_refs: Vec<&str> = bool_flags.iter().copied().collect();
node = node.with_flags(&value_refs).with_boolean_flags(&bool_refs);
for d in directives {
if let Some(h) = d.help {
node = node.with_flag_help(d.cli_flag, h);
}
if let Some(values) = &d.values {
let provider: ValueProvider = values.clone().into_provider();
node = node.with_value_provider(d.cli_flag, provider);
}
}
node
}
#[derive(Debug, Clone)]
pub struct ParsedCommand<'a> {
pub path: Vec<&'a str>,
pub flags: std::collections::BTreeMap<String, Vec<String>>,
pub positionals: Vec<&'a str>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
MissingValue {
flag: String,
},
UnknownFlag {
flag: String,
path: Vec<String>,
},
InvalidValue {
flag: String,
value: String,
},
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::MissingValue { flag } =>
write!(f, "flag '{}' expects a value but none was given", flag),
ParseError::UnknownFlag { flag, path } =>
write!(f, "unknown flag '{}' at '{}'", flag, path.join(" ")),
ParseError::InvalidValue { flag, value } =>
write!(f, "invalid value '{}' for flag '{}'", value, flag),
}
}
}
impl std::error::Error for ParseError {}
pub fn parse_argv<'a>(
tree: &CommandTree,
argv: &[&'a str],
) -> Result<ParsedCommand<'a>, ParseError> {
parse_argv_inner(tree, argv, false)
}
pub fn parse_argv_lenient<'a>(
tree: &CommandTree,
argv: &[&'a str],
) -> Result<ParsedCommand<'a>, ParseError> {
parse_argv_inner(tree, argv, true)
}
fn parse_argv_inner<'a>(
tree: &CommandTree,
argv: &[&'a str],
lenient: bool,
) -> Result<ParsedCommand<'a>, ParseError> {
let mut path: Vec<&'a str> = Vec::new();
let mut flags: std::collections::BTreeMap<String, Vec<String>> =
std::collections::BTreeMap::new();
let mut positionals: Vec<&'a str> = Vec::new();
let mut node: &Node = &tree.root;
let mut i = 0usize;
while i < argv.len() {
let arg = argv[i];
if let Some(child) = node.child(arg) {
path.push(arg);
node = child;
i += 1;
continue;
}
if let Some(stripped) = arg.strip_prefix("--") {
if let Some(eq_pos) = stripped.find('=') {
let key = format!("--{}", &stripped[..eq_pos]);
let val = stripped[eq_pos + 1..].to_string();
if flag_is_known(node, tree, &key) {
flags.entry(key).or_default().push(val);
} else if lenient {
positionals.push(arg);
} else {
return Err(ParseError::UnknownFlag {
flag: key,
path: path.iter().map(|s| s.to_string()).collect(),
});
}
i += 1;
continue;
}
let key = arg.to_string();
if !flag_is_known(node, tree, &key) {
if lenient {
positionals.push(arg);
i += 1;
continue;
} else {
return Err(ParseError::UnknownFlag {
flag: key,
path: path.iter().map(|s| s.to_string()).collect(),
});
}
}
if flag_is_boolean(node, &key) {
flags.entry(key).or_default().push(String::new());
i += 1;
} else {
if i + 1 >= argv.len() {
return Err(ParseError::MissingValue { flag: key });
}
let val = argv[i + 1].to_string();
flags.entry(key).or_default().push(val);
i += 2;
}
continue;
}
if arg.starts_with('-') && arg.len() > 1 && !arg.starts_with("--") {
positionals.push(arg);
i += 1;
continue;
}
positionals.push(arg);
i += 1;
}
Ok(ParsedCommand { path, flags, positionals })
}
fn flag_is_known(node: &Node, tree: &CommandTree, flag: &str) -> bool {
if node.flags.iter().any(|o| flag_canonical_match(o, flag)) {
return true;
}
if tree.global_value_providers.contains_key(flag) {
return true;
}
false
}
fn flag_is_boolean(node: &Node, flag: &str) -> bool {
node.boolean_flags.contains(flag)
}
fn flag_canonical_match(declared: &str, given: &str) -> bool {
let d = declared.trim_end_matches('=');
d == given
}
pub fn complete_expanded(tree: &CommandTree, words: &[&str]) -> Vec<String> {
let partial = if words.len() > 1 { words.last().unwrap_or(&"") } else { &"" };
let completed = if words.len() > 2 { &words[1..words.len() - 1] } else { &[] };
if !completed.is_empty() || !partial.is_empty() {
return complete(tree, words);
}
let mut results = Vec::new();
for (name, node) in &tree.root.children {
if name == "help" || name.starts_with('-') {
continue;
}
if !node.children.is_empty() {
for sub_name in node.children.keys() {
results.push(format!("{} {}", name, sub_name));
}
} else if !tree.hidden.contains(name.as_str()) {
results.push(name.to_string());
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
fn test_tree() -> CommandTree {
CommandTree::new("testapp")
.command("run", Node::leaf_with_flags(
&["cycles=", "threads=", "adapter=", "workload="],
&["--strict", "--tui"],
).with_dynamic_options(dynamic_workload_params))
.command("bench", Node::group(vec![
("gk", Node::leaf_with_flags(
&["cycles=", "threads=", "--cycles", "--threads"],
&["--explain"],
)),
]))
}
fn dynamic_workload_params(_partial: &str, context: &[&str]) -> Vec<String> {
for word in context {
if let Some(path) = word.strip_prefix("workload=") {
if path == "test_keyvalue.yaml" {
return vec!["keyspace=".into(), "table=".into(), "keycount=".into()];
}
}
}
Vec::new()
}
#[test]
fn root_completions() {
let tree = test_tree();
let candidates = complete(&tree, &["testapp", ""]);
assert!(candidates.contains(&"bench".to_string()));
assert!(candidates.contains(&"run".to_string()));
}
#[test]
fn run_shows_all_options() {
let tree = test_tree();
let candidates = complete(&tree, &["testapp", "run", ""]);
assert!(candidates.contains(&"cycles=".to_string()));
assert!(candidates.contains(&"--strict".to_string()));
assert!(candidates.contains(&"adapter=".to_string()));
}
#[test]
fn run_filters_consumed_bare_param() {
let tree = test_tree();
let candidates = complete(&tree, &["testapp", "run", "cycles=1000", ""]);
assert!(!candidates.contains(&"cycles=".to_string()));
assert!(candidates.contains(&"threads=".to_string()));
assert!(candidates.contains(&"--strict".to_string()));
}
#[test]
fn run_filters_consumed_flag() {
let tree = test_tree();
let candidates = complete(&tree, &["testapp", "run", "--strict", ""]);
assert!(!candidates.contains(&"--strict".to_string()));
assert!(candidates.contains(&"cycles=".to_string()));
}
#[test]
fn bench_gk_filters_consumed() {
let tree = test_tree();
let candidates = complete(&tree, &["testapp", "bench", "gk", "expr", "--cycles=1000000", "--threads=20", ""]);
assert!(!candidates.contains(&"--cycles".to_string()));
assert!(!candidates.contains(&"cycles=".to_string()));
assert!(!candidates.contains(&"--threads".to_string()));
assert!(!candidates.contains(&"threads=".to_string()));
assert!(candidates.contains(&"--explain".to_string()));
}
#[test]
fn partial_match_bare_param() {
let tree = test_tree();
let candidates = complete(&tree, &["testapp", "run", "cy"]);
assert!(candidates.contains(&"cycles=".to_string()));
assert!(!candidates.contains(&"--strict".to_string()));
}
#[test]
fn dynamic_options_from_workload() {
let tree = test_tree();
let candidates = complete(&tree, &["testapp", "run", "workload=test_keyvalue.yaml", ""]);
assert!(candidates.contains(&"keyspace=".to_string()), "dynamic param 'keyspace=' should appear");
assert!(candidates.contains(&"table=".to_string()), "dynamic param 'table=' should appear");
assert!(candidates.contains(&"keycount=".to_string()), "dynamic param 'keycount=' should appear");
assert!(candidates.contains(&"--strict".to_string()));
assert!(!candidates.contains(&"workload=".to_string()));
}
#[test]
fn dynamic_options_filtered_when_consumed() {
let tree = test_tree();
let candidates = complete(&tree, &["testapp", "run", "workload=test_keyvalue.yaml", "keyspace=mykeyspace", ""]);
assert!(!candidates.contains(&"keyspace=".to_string()), "keyspace= already used");
assert!(candidates.contains(&"table=".to_string()), "table= still available");
}
#[test]
fn dynamic_options_partial_match() {
let tree = test_tree();
let candidates = complete(&tree, &["testapp", "run", "workload=test_keyvalue.yaml", "key"]);
assert!(candidates.contains(&"keyspace=".to_string()));
assert!(candidates.contains(&"keycount=".to_string()));
assert!(!candidates.contains(&"table=".to_string()), "table= doesn't start with 'key'");
}
#[test]
fn no_dynamic_options_without_workload() {
let tree = test_tree();
let candidates = complete(&tree, &["testapp", "run", ""]);
assert!(!candidates.contains(&"keyspace=".to_string()), "no workload= means no dynamic params");
}
#[test]
fn word_matches_exact_flag() {
assert!(word_matches_option("--strict", "--strict"));
assert!(!word_matches_option("--strict", "--tui"));
}
#[test]
fn word_matches_bare_key_value() {
assert!(word_matches_option("cycles=1000", "cycles="));
assert!(!word_matches_option("threads=4", "cycles="));
}
#[test]
fn word_matches_dashed_to_bare_equivalence() {
assert!(word_matches_option("--cycles=1000", "cycles="));
assert!(word_matches_option("cycles=1000", "--cycles"));
}
fn stratified_tree() -> CommandTree {
CommandTree::new("nbrs")
.command("run",
Node::leaf(&["--cycles="])
.with_category("workloads").with_level(1))
.command("--inspector",
Node::leaf(&[])
.with_category("tools").with_level(2))
.command("--summary",
Node::leaf(&[])
.with_category("tools").with_level(2))
.command("describe",
Node::leaf(&[])
.with_category("documentation").with_level(3))
.command("bench",
Node::leaf(&[])
.with_category("benchmark").with_level(3))
}
#[test]
fn tap1_shows_only_level1_commands() {
let tree = stratified_tree();
let cands = complete_at_tap(&tree, &["nbrs"], 1);
assert_eq!(cands, vec!["run".to_string()]);
}
#[test]
fn tap2_adds_level2_commands() {
let tree = stratified_tree();
let cands = complete_at_tap(&tree, &["nbrs"], 2);
assert!(cands.contains(&"run".to_string()));
assert!(cands.contains(&"--inspector".to_string()));
assert!(cands.contains(&"--summary".to_string()));
assert!(!cands.contains(&"describe".to_string()),
"level-3 'describe' should not appear at tap 2");
}
#[test]
fn tap3_shows_everything() {
let tree = stratified_tree();
let cands = complete_at_tap(&tree, &["nbrs"], 3);
assert!(cands.contains(&"run".to_string()));
assert!(cands.contains(&"--inspector".to_string()));
assert!(cands.contains(&"describe".to_string()));
assert!(cands.contains(&"bench".to_string()));
}
#[test]
fn level_filter_does_not_block_partial_match() {
let tree = stratified_tree();
let cands = complete_at_tap(&tree, &["nbrs", "--ins"], 1);
assert!(cands.contains(&"--inspector".to_string()),
"partial-prefix matches should bypass the tap-tier filter");
}
#[test]
fn rotating_tap1_baseline_is_layer1_only() {
let tree = stratified_tree();
let cands = complete_rotating(&tree, &["nbrs"], 1);
assert_eq!(cands, vec!["run".to_string()]);
}
#[test]
fn rotating_tap2_is_cumulative_superset() {
let tree = stratified_tree();
let cands = complete_rotating(&tree, &["nbrs"], 2);
assert!(cands.contains(&"run".to_string()),
"layer-1 'run' must remain visible at tap 2");
assert!(cands.contains(&"--inspector".to_string()),
"layer-2 '--inspector' must appear at tap 2");
assert!(cands.contains(&"--summary".to_string()),
"layer-2 '--summary' must appear at tap 2");
}
#[test]
fn rotating_tap2_orders_by_layer() {
let tree = stratified_tree();
let cands = complete_rotating(&tree, &["nbrs"], 2);
let pos = |name: &str| cands.iter().position(|s| s == name).unwrap();
assert!(pos("run") < pos("--inspector"),
"layer-1 'run' must precede layer-2 '--inspector'");
assert!(pos("run") < pos("--summary"),
"layer-1 'run' must precede layer-2 '--summary'");
assert!(pos("--inspector") < pos("--summary"),
"within a layer, alphabetical: '--inspector' before '--summary'");
}
#[test]
fn rotating_tap3_at_max_includes_all_layers() {
let tree = stratified_tree();
let cands = complete_rotating(&tree, &["nbrs"], 3);
assert!(cands.contains(&"run".to_string()));
assert!(cands.contains(&"--inspector".to_string()));
assert!(cands.contains(&"--summary".to_string()));
assert!(cands.contains(&"describe".to_string()));
assert!(cands.contains(&"bench".to_string()));
let pos = |name: &str| cands.iter().position(|s| s == name).unwrap();
assert!(pos("run") < pos("--inspector"));
assert!(pos("--summary") < pos("bench"));
assert!(pos("--summary") < pos("describe"));
assert!(pos("bench") < pos("describe"));
}
#[test]
fn rotating_tap2_at_value_position_does_not_pollute_stdout() {
let provider: ValueProvider = std::sync::Arc::new(
|_partial: &str, _ctx: &[&str]| vec!["L2".into(), "IP".into(), "COSINE".into()],
);
let leaf = Node::leaf_with_flags(&["--metric"], &[])
.with_value_provider("--metric", provider)
.with_flag_help("--metric", "Distance metric (L2 / IP / COSINE)");
let tree = CommandTree::new("nbrs").command("run", leaf);
let tap1 = complete_rotating(&tree, &["nbrs", "run", "--metric", ""], 1);
let tap2 = complete_rotating(&tree, &["nbrs", "run", "--metric", ""], 2);
assert_eq!(tap1, tap2, "rapid double-tap must not change the candidate list");
assert!(tap1.contains(&"L2".to_string()));
}
#[test]
fn value_position_rapid_tap_reaches_tap_count_two() {
let leaf = Node::leaf_with_flags(&["--top-k"], &[])
.with_flag_help("--top-k", "HeavyHitters top-K capacity")
.with_value_provider(
"--top-k",
std::sync::Arc::new(|_p: &str, _c: &[&str]| Vec::new()),
);
let _tree = CommandTree::new("nbrs").command("run", leaf.clone());
let max_level = 2;
let key = "nbrs run --top-k ";
let (tap1, st1) = next_tap_state(None, 1_000, key, max_level);
assert_eq!(tap1, 1, "first tap is always 1");
let (tap2, _st2) = next_tap_state(Some((st1, key)), 1_100, key, max_level);
assert_eq!(tap2, 2, "rapid second tap must reach 2 at a value position");
}
#[test]
fn flag_help_round_trips_through_leaf() {
let leaf = Node::leaf_with_flags(&["--metric"], &[])
.with_flag_help("--metric", "Distance metric");
assert_eq!(leaf.flag_help_for("--metric"), Some("Distance metric"));
assert!(leaf.flag_help_for("--unknown").is_none());
}
#[test]
fn global_flag_help_round_trips() {
let tree = CommandTree::new("nbrs")
.global_flag_help("--dataset", "Dataset name in the configured catalogs");
assert_eq!(
tree.global_flag_help_for("--dataset"),
Some("Dataset name in the configured catalogs"),
);
assert!(tree.global_flag_help_for("--missing").is_none());
}
fn replay_taps(
max_level: u32,
key: &str,
events: &[u128], ) -> Vec<u32> {
let mut state: Option<TapState> = None;
let mut shown = Vec::with_capacity(events.len());
for &t in events {
let prev = state.map(|s| (s, key));
let (tap, next) = next_tap_state(prev, t, key, max_level);
shown.push(tap);
state = Some(next);
}
shown
}
#[test]
fn cadence_cold_tap_is_layer1() {
let shown = replay_taps(3, "veks", &[1_000]);
assert_eq!(shown, vec![1]);
}
#[test]
fn cadence_rapid_advances_through_layers() {
let shown = replay_taps(3, "veks", &[1_000, 1_100, 1_200]);
assert_eq!(shown, vec![1, 2, 3]);
}
#[test]
fn cadence_rapid_past_max_resets_cycle() {
let shown = replay_taps(3, "veks", &[1_000, 1_100, 1_200, 1_300]);
assert_eq!(shown, vec![1, 2, 3, 1]);
}
#[test]
fn cadence_pause_resets_to_layer1() {
let shown = replay_taps(3, "veks", &[1_000, 1_500]);
assert_eq!(shown, vec![1, 1]);
}
#[test]
fn cadence_key_change_resets_to_layer1() {
let mut state: Option<TapState> = None;
let (t1, st1) = next_tap_state(None, 1_000, "veks", 3);
state = Some(st1);
assert_eq!(t1, 1);
let prev = state.map(|s| (s, "veks"));
let (t2, _) = next_tap_state(prev, 1_100, "veks compute", 3);
assert_eq!(t2, 1);
}
#[test]
fn cadence_max_level_2_alternates() {
let shown = replay_taps(2, "veks", &[1_000, 1_100, 1_200, 1_300, 1_400]);
assert_eq!(shown, vec![1, 2, 1, 2, 1]);
}
#[test]
fn cadence_max_level_1_pinned() {
let shown = replay_taps(1, "veks", &[1_000, 1_100, 1_200, 1_300]);
assert_eq!(shown, vec![1, 1, 1, 1]);
}
#[test]
fn cadence_advance_window_boundary() {
let shown = replay_taps(3, "veks", &[1_000, 1_000 + TAP_ADVANCE_MS]);
assert_eq!(shown, vec![1, 1], "tap exactly at the boundary is fresh");
let shown = replay_taps(3, "veks", &[1_000, 1_000 + TAP_ADVANCE_MS - 1]);
assert_eq!(shown, vec![1, 2], "tap just inside the boundary is rapid");
}
#[test]
fn closed_values_static_completes_and_validates() {
let cv = ClosedValues::Static(&["L2", "IP", "COSINE"]);
assert_eq!(cv.complete(""), vec!["L2", "IP", "COSINE"]);
assert_eq!(cv.complete("CO"), vec!["COSINE"]);
assert_eq!(cv.complete("Z"), Vec::<String>::new());
assert!(cv.validate("L2"));
assert!(cv.validate("COSINE"));
assert!(!cv.validate("bogus"));
}
#[test]
fn closed_values_owned_completes_and_validates() {
let cv = ClosedValues::Owned(vec!["alpha".into(), "beta".into(), "gamma".into()]);
assert_eq!(cv.complete("a"), vec!["alpha"]);
assert!(cv.validate("beta"));
assert!(!cv.validate("delta"));
}
#[test]
fn closed_values_into_provider_filters() {
let cv = ClosedValues::Static(&["a", "ab", "abc"]);
let provider: ValueProvider = cv.into_provider();
assert_eq!(provider("ab", &[]), vec!["ab", "abc"]);
}
#[test]
fn aliases_share_one_provider() {
let provider: ValueProvider = std::sync::Arc::new(|partial: &str, _| {
["red", "green", "blue"].iter()
.filter(|s| s.starts_with(partial))
.map(|s| s.to_string())
.collect()
});
let leaf = Node::leaf(&["--color", "--colour", "--col"])
.with_value_provider_aliases(&["--color", "--colour", "--col"], provider);
let tree = CommandTree::new("paint").command("draw", leaf);
let out_a = complete(&tree, &["paint", "draw", "--color", "g"]);
let out_b = complete(&tree, &["paint", "draw", "--colour", "g"]);
let out_c = complete(&tree, &["paint", "draw", "--col", "g"]);
assert_eq!(out_a, vec!["green"]);
assert_eq!(out_a, out_b);
assert_eq!(out_b, out_c);
}
#[test]
fn render_usage_includes_help_flags_and_subcommands() {
let leaf = Node::leaf_with_flags(&["--metric"], &["--verbose"])
.with_help("Compute KNN over base vectors")
.with_flag_help("--metric", "Distance metric: L2 / IP / COSINE")
.with_flag_help("--verbose", "Print per-step progress");
let tree = CommandTree::new("app")
.command("compute", Node::group(vec![
("knn", leaf),
]).with_help("Compute commands"));
let knn_node = tree.root.child("compute").unwrap().child("knn").unwrap();
let out = render_usage(knn_node, &["app", "compute", "knn"]);
assert!(out.contains("USAGE: app compute knn"), "{}", out);
assert!(out.contains("Compute KNN over base vectors"), "{}", out);
assert!(out.contains("--metric"), "{}", out);
assert!(out.contains("Distance metric"), "{}", out);
assert!(out.contains("--verbose"), "{}", out);
let compute_node = tree.root.child("compute").unwrap();
let out = render_usage(compute_node, &["app", "compute"]);
assert!(out.contains("Compute commands"));
assert!(out.contains("SUBCOMMANDS:"));
assert!(out.contains("knn"));
}
#[test]
fn promoted_global_lifts_to_tree_globals() {
let provider = fn_provider(|p: &str, _: &[&str]| {
["alpha", "beta"].iter()
.filter(|s| s.starts_with(p))
.map(|s| s.to_string())
.collect()
});
let tree = CommandTree::new("app")
.command("run", Node::leaf(&["--name"])
.with_promoted_global("--name", provider))
.lift_promoted_globals();
assert!(tree.global_value_providers.contains_key("--name"));
let out = complete(&tree, &["app", "run", "--name", "a"]);
assert_eq!(out, vec!["alpha"]);
}
#[test]
fn with_auto_help_attaches_help_flag_recursively() {
let tree = CommandTree::new("app")
.command("compute", Node::group(vec![
("knn", Node::leaf(&["--metric"])),
]))
.command("run", Node::leaf(&["--input"]))
.with_auto_help();
let knn = tree.root.child("compute").unwrap().child("knn").unwrap();
assert!(knn.flags().iter().any(|f| f == "--help"),
"leaf 'knn' should have --help auto-attached");
assert!(knn.is_flag("--help"), "--help should be boolean");
assert!(knn.flag_help_for("--help").is_some());
let compute = tree.root.child("compute").unwrap();
assert!(compute.flags().iter().any(|f| f == "--help"),
"group 'compute' should have --help auto-attached");
let run = tree.root.child("run").unwrap();
assert!(run.flags().iter().any(|f| f == "--help"));
let parsed = parse_argv(&tree, &["compute", "knn", "--help"]).unwrap();
assert_eq!(parsed.path, vec!["compute", "knn"]);
assert!(parsed.flags.contains_key("--help"));
}
#[test]
fn with_auto_help_doesnt_double_register() {
let tree = CommandTree::new("app")
.command("run", Node::leaf_with_flags(&[], &["--help"]))
.with_auto_help();
let run = tree.root.child("run").unwrap();
let count = run.flags().iter().filter(|f| **f == "--help").count();
assert_eq!(count, 1, "auto-help must be idempotent");
}
#[test]
fn with_metricsql_at_attaches_provider() {
use std::sync::Arc;
struct EmptyCatalog;
impl crate::providers::MetricsqlCatalog for EmptyCatalog {
fn metric_names(&self, p: &str) -> Vec<String> {
["up", "node_cpu"].iter()
.filter(|n| n.starts_with(p))
.map(|s| s.to_string())
.collect()
}
fn label_keys(&self, _: &str, p: &str) -> Vec<String> {
["job", "instance"].iter()
.filter(|n| n.starts_with(p))
.map(|s| s.to_string())
.collect()
}
fn label_values(&self, _: &str, _: &str, p: &str) -> Vec<String> {
["prometheus"].iter()
.filter(|n| n.starts_with(p))
.map(|s| s.to_string())
.collect()
}
}
let tree = CommandTree::new("nbrs")
.command("query", Node::leaf(&[]))
.with_metricsql_at(&["query"], Arc::new(EmptyCatalog));
let query = tree.root.child("query").unwrap();
assert!(query.subtree_provider().is_some(),
"with_metricsql_at must attach a subtree provider");
}
#[test]
fn hybrid_node_completes_children_and_flags_together() {
let report = Node::group(vec![
("base", Node::leaf(&[])),
("filtered", Node::leaf(&[])),
])
.with_flags(&["--workload"])
.with_boolean_flags(&["--dry-run"]);
let tree = CommandTree::new("app").command("report", report);
let cands = complete(&tree, &["app", "report", ""]);
let pos = |name: &str| cands.iter().position(|s| s == name);
assert!(pos("base").is_some(), "expected 'base' subcommand: {:?}", cands);
assert!(pos("filtered").is_some(), "expected 'filtered' subcommand: {:?}", cands);
assert!(pos("--workload").is_some(), "expected '--workload' flag: {:?}", cands);
assert!(pos("--dry-run").is_some(), "expected '--dry-run' flag: {:?}", cands);
assert!(pos("base").unwrap() < pos("--workload").unwrap());
assert!(pos("filtered").unwrap() < pos("--dry-run").unwrap());
let cands = complete(&tree, &["app", "report", "--workload", ""]);
assert!(cands.is_empty() || !cands.iter().any(|c| c == "base"),
"value position must not list children: {:?}", cands);
}
#[test]
fn group_flags_appear_via_options_accessor() {
let group = Node::group(vec![
("sub", Node::leaf(&[])),
])
.with_flags(&["--workload"])
.with_boolean_flags(&["--dry-run"]);
assert!(group.options().iter().any(|o| *o == "--workload"));
assert!(group.options().iter().any(|o| *o == "--dry-run"));
assert!(group.is_group());
assert!(!group.is_leaf());
}
#[test]
fn subtree_provider_takes_over_completion() {
let provider: SubtreeProvider = std::sync::Arc::new(|pp: &PartialParse| {
vec![format!("{}:{}", pp.tree_path.join("/"), pp.partial)]
});
let tree = CommandTree::new("app")
.command("metrics",
Node::group(vec![("match", Node::leaf(&[]))])
.with_subtree_provider(provider));
let out = complete(&tree, &["app", "metrics", "match", "foo"]);
assert_eq!(out, vec!["metrics/match:foo".to_string()]);
}
#[test]
fn rapid_tap_threads_full_count_through_subtree_provider() {
let provider: SubtreeProvider = std::sync::Arc::new(|pp: &PartialParse| {
vec![format!("tap={}", pp.tap_count)]
});
let tree = CommandTree::new("app")
.command("query",
Node::leaf(&[]).with_subtree_provider(provider));
let words = ["app", "query", ""];
for tap in [1u32, 2, 3, 7] {
let out = complete_rotating_with_raw(&tree, &words, tap, "app query ", 10);
assert_eq!(out, vec![format!("tap={}", tap)],
"tap_count {tap} did not reach the subtree provider: {out:?}");
}
}
#[test]
fn extras_round_trip_via_downcast() {
#[derive(Debug, PartialEq, Eq)]
struct Handler(u32);
let leaf = Node::leaf(&[])
.with_extras(Extras::new(Handler(42)));
let h: &Handler = leaf.extras().unwrap().downcast::<Handler>().unwrap();
assert_eq!(h.0, 42);
assert!(leaf.extras().unwrap().downcast::<u8>().is_none());
}
#[test]
fn parse_argv_walks_subcommands_and_collects_flags() {
let tree = CommandTree::new("app")
.command("compute", Node::group(vec![
("knn", Node::leaf_with_flags(&["--metric"], &["--verbose"])),
]));
let parsed = parse_argv(&tree, &[
"compute", "knn", "--metric", "L2", "--verbose", "data.fvec",
]).unwrap();
assert_eq!(parsed.path, vec!["compute", "knn"]);
assert_eq!(parsed.flags["--metric"], vec!["L2".to_string()]);
assert_eq!(parsed.flags["--verbose"], vec!["".to_string()]);
assert_eq!(parsed.positionals, vec!["data.fvec"]);
}
#[test]
fn parse_argv_handles_eq_form() {
let tree = CommandTree::new("app")
.command("run", Node::leaf(&["--name"]));
let parsed = parse_argv(&tree, &["run", "--name=foo"]).unwrap();
assert_eq!(parsed.flags["--name"], vec!["foo".to_string()]);
}
#[test]
fn parse_argv_repeats_collect_into_vec() {
let tree = CommandTree::new("app")
.command("run", Node::leaf(&["--set"]));
let parsed = parse_argv(&tree, &[
"run", "--set", "a=1", "--set", "b=2",
]).unwrap();
assert_eq!(parsed.flags["--set"], vec!["a=1".to_string(), "b=2".to_string()]);
}
#[test]
fn parse_argv_unknown_flag_strict_errors() {
let tree = CommandTree::new("app")
.command("run", Node::leaf(&["--known"]));
let err = parse_argv(&tree, &["run", "--bogus", "x"]).unwrap_err();
assert!(matches!(err, ParseError::UnknownFlag { .. }));
}
#[test]
fn parse_argv_unknown_flag_lenient_falls_through() {
let tree = CommandTree::new("app")
.command("run", Node::leaf(&["--known"]));
let parsed = parse_argv_lenient(&tree, &["run", "--bogus", "x"]).unwrap();
assert_eq!(parsed.positionals, vec!["--bogus", "x"]);
}
#[test]
fn parse_argv_missing_value_errors() {
let tree = CommandTree::new("app")
.command("run", Node::leaf(&["--name"]));
let err = parse_argv(&tree, &["run", "--name"]).unwrap_err();
assert!(matches!(err, ParseError::MissingValue { .. }));
}
#[test]
fn apply_directives_registers_flags_help_and_providers() {
const DIRS: &[Directive] = &[
Directive::closed("--metric", &["L2", "IP", "COSINE"])
.with_help("Distance metric"),
Directive::value("--name").with_help("Name of the run"),
Directive::boolean("--verbose").with_help("Verbose output"),
];
let leaf = apply_directives(Node::leaf(&[]), DIRS);
let tree = CommandTree::new("app").command("run", leaf);
let out = complete(&tree, &["app", "run", "--metric", "C"]);
assert_eq!(out, vec!["COSINE"]);
let leaf = tree.root.child("run").unwrap();
assert_eq!(leaf.flag_help_for("--metric"), Some("Distance metric"));
assert_eq!(leaf.flag_help_for("--verbose"), Some("Verbose output"));
let parsed = parse_argv(&tree, &[
"run", "--metric", "L2", "--verbose", "--name", "test",
]).unwrap();
assert_eq!(parsed.flags["--metric"], vec!["L2".to_string()]);
assert_eq!(parsed.flags["--verbose"], vec!["".to_string()]);
assert_eq!(parsed.flags["--name"], vec!["test".to_string()]);
}
#[test]
fn nodes_without_level_default_to_visible() {
let tree = CommandTree::new("legacy")
.command("run", Node::leaf(&[]))
.command("describe", Node::leaf(&[]));
let cands = complete_at_tap(&tree, &["legacy"], 1);
assert!(cands.contains(&"run".to_string()));
assert!(cands.contains(&"describe".to_string()));
}
#[test]
fn strict_node_with_full_metadata_compiles() {
let _tree = CommandTree::new("app")
.strict_command(
"run",
StrictNode::leaf(&["--cycles="])
.with_category("workloads")
.with_level(1),
);
}
#[test]
fn runtime_validate_reports_missing_metadata() {
let tree = CommandTree::new("app")
.command("run",
Node::leaf(&[]).with_category("workloads").with_level(1))
.command("undertagged", Node::leaf(&[]));
let errors = tree.validate().unwrap_err();
assert!(errors.iter().any(|e| matches!(e,
MetadataError::MissingCategory { command } if command == "undertagged")));
assert!(errors.iter().any(|e| matches!(e,
MetadataError::MissingLevel { command } if command == "undertagged")));
assert!(!errors.iter().any(|e| matches!(e,
MetadataError::MissingCategory { command } if command == "run")));
}
#[test]
fn runtime_validate_passes_when_all_tagged() {
let tree = CommandTree::new("app")
.command("run",
Node::leaf(&[]).with_category("workloads").with_level(1))
.command("describe",
Node::leaf(&[]).with_category("docs").with_level(3));
assert!(tree.validate().is_ok());
}
#[test]
#[should_panic(expected = "without Node::with_category")]
fn require_metadata_panics_on_undertagged_command() {
let _tree = CommandTree::new("app")
.require_metadata()
.command("bad", Node::leaf(&[])); }
}