use std::collections::BTreeMap;
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)
}
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 enum Node {
Leaf {
options: Vec<String>,
flags: std::collections::HashSet<String>,
value_providers: BTreeMap<String, ValueProvider>,
dynamic_options: Option<DynamicOptionsProvider>,
category: Option<String>,
level: Option<u32>,
},
Group {
children: BTreeMap<String, Node>,
category: Option<String>,
level: Option<u32>,
},
}
impl std::fmt::Debug for Node {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Node::Leaf { options, flags, value_providers, dynamic_options, category, level } => {
f.debug_struct("Leaf")
.field("options", options)
.field("flags", flags)
.field("value_providers", &value_providers.keys().collect::<Vec<_>>())
.field("has_dynamic_options", &dynamic_options.is_some())
.field("category", category)
.field("level", level)
.finish()
}
Node::Group { children, category, level } => {
f.debug_struct("Group")
.field("children", children)
.field("category", category)
.field("level", level)
.finish()
}
}
}
}
impl Node {
pub fn leaf(options: &[&str]) -> Self {
Node::Leaf {
options: options.iter().map(|s| s.to_string()).collect(),
flags: std::collections::HashSet::new(),
value_providers: BTreeMap::new(),
dynamic_options: None,
category: None,
level: None,
}
}
pub fn leaf_with_flags(options: &[&str], flags: &[&str]) -> Self {
Node::Leaf {
options: options.iter().chain(flags.iter()).map(|s| s.to_string()).collect(),
flags: flags.iter().map(|s| s.to_string()).collect(),
value_providers: BTreeMap::new(),
dynamic_options: None,
category: None,
level: None,
}
}
pub fn with_value_provider(mut self, option: &str, provider: ValueProvider) -> Self {
if let Node::Leaf { ref mut value_providers, .. } = self {
value_providers.insert(option.to_string(), provider);
}
self
}
pub fn with_dynamic_options(mut self, provider: DynamicOptionsProvider) -> Self {
if let Node::Leaf { ref mut dynamic_options, .. } = self {
*dynamic_options = Some(provider);
}
self
}
pub fn with_category(mut self, cat: &str) -> Self {
match &mut self {
Node::Leaf { category, .. } => *category = Some(cat.to_string()),
Node::Group { category, .. } => *category = Some(cat.to_string()),
}
self
}
pub fn with_level(mut self, lvl: u32) -> Self {
match &mut self {
Node::Leaf { level, .. } => *level = Some(lvl),
Node::Group { level, .. } => *level = Some(lvl),
}
self
}
pub fn category(&self) -> Option<&str> {
match self {
Node::Leaf { category, .. } => category.as_deref(),
Node::Group { category, .. } => category.as_deref(),
}
}
pub fn level(&self) -> u32 {
self.level_explicit().unwrap_or(DEFAULT_LEVEL)
}
pub fn level_explicit(&self) -> Option<u32> {
match self {
Node::Leaf { level, .. } => *level,
Node::Group { level, .. } => *level,
}
}
pub fn is_flag(&self, option: &str) -> bool {
match self {
Node::Leaf { flags, .. } => flags.contains(option),
_ => false,
}
}
pub fn group(children: Vec<(&str, Node)>) -> Self {
Node::Group {
children: children.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect(),
category: None,
level: None,
}
}
pub fn empty_group() -> Self {
Node::Group {
children: BTreeMap::new(),
category: None,
level: None,
}
}
pub fn with_child(mut self, name: &str, child: Node) -> Self {
match &mut self {
Node::Group { children, .. } => { children.insert(name.to_string(), child); }
Node::Leaf { .. } => panic!("cannot add child to leaf node"),
}
self
}
pub fn child_names(&self) -> Vec<&str> {
match self {
Node::Group { children, .. } => children.keys().map(|k| k.as_str()).collect(),
Node::Leaf { .. } => Vec::new(),
}
}
pub fn child(&self, name: &str) -> Option<&Node> {
match self {
Node::Group { children, .. } => children.get(name),
Node::Leaf { .. } => None,
}
}
pub fn options(&self) -> Vec<&str> {
match self {
Node::Leaf { options, .. } => options.iter().map(|s| s.as_str()).collect(),
Node::Group { .. } => Vec::new(),
}
}
}
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 strict_metadata: bool,
}
impl CommandTree {
pub fn max_level(&self) -> u32 {
let mut max = DEFAULT_LEVEL;
if let Node::Group { children, .. } = &self.root {
for (_, child) in children {
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(),
strict_metadata: false,
}
}
pub fn require_metadata(mut self) -> Self {
self.strict_metadata = true;
self
}
pub fn validate(&self) -> Result<(), Vec<MetadataError>> {
let mut errors = Vec::new();
if let Node::Group { children, .. } = &self.root {
for (name, node) in 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
}
}
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 let Node::Group { children, .. } = node {
let mut candidates: Vec<(u32, String)> = 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> {
let completed: &[&str] = if words.len() > 1 { &words[1..words.len() - 1] } else { &[] };
let mut node = &tree.root;
for &word in completed {
match node.child(word) {
Some(child) => node = child,
None => break,
}
}
let max = max_level_of_children(node).max(1);
let only = ((tap_count.saturating_sub(1)) % max) + 1;
complete_at_level_only(tree, words, only)
}
pub(crate) fn max_level_of_children(node: &Node) -> u32 {
let mut max = DEFAULT_LEVEL;
if let Node::Group { children, .. } = node {
for (_, child) in children {
if child.level() > max {
max = child.level();
}
}
}
max
}
pub fn complete_at_tap(tree: &CommandTree, words: &[&str], tap_count: u32) -> 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;
for (i, &word) in completed.iter().enumerate() {
match node.child(word) {
Some(child) => { node = child; remaining_start = i + 1; }
None => break,
}
}
let remaining = &completed[remaining_start..];
if let Some(&prev_word) = completed.last()
&& let Some(provider) = tree.global_value_providers.get(prev_word) {
return provider(partial, remaining);
}
match node {
Node::Group { children, .. } => {
let mut candidates: Vec<String> = 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, _)| k.to_string())
.collect();
candidates.sort_by(|a, b| {
a.starts_with('-').cmp(&b.starts_with('-')).then_with(|| a.cmp(b))
});
candidates
}
Node::Leaf { options, flags, value_providers, dynamic_options, .. } => {
if let Some(&prev_word) = remaining.last()
&& prev_word.starts_with("--") && !prev_word.contains('=') && !flags.contains(prev_word) {
if let Some(provider) = 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) = value_providers.get(&key_eq)
.or_else(|| 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 all_options: Vec<String> = options.clone();
if let Some(provider) = dynamic_options {
let dynamic = provider(partial, remaining);
for opt in dynamic {
if !all_options.contains(&opt) {
all_options.push(opt);
}
}
}
let consumed = consumed_keys(remaining, &all_options);
let mut candidates: Vec<String> = all_options.iter()
.filter(|o| o.starts_with(partial) && !is_consumed(o, &consumed))
.map(|o| o.to_string())
.collect();
for global_opt in tree.global_value_providers.keys() {
if global_opt.starts_with(partial) && !candidates.contains(global_opt) {
candidates.push(global_opt.clone());
}
}
candidates.sort_by(|a, b| {
a.starts_with('-').cmp(&b.starts_with('-')).then_with(|| a.cmp(b))
});
candidates
}
}
}
#[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'
COMPREPLY=($({env_var}=bash _COMP_SHELL_PID=$$ "{completer}" "$COMP_LINE" "$COMP_POINT" 2>/dev/null))
if [[ ${{#COMPREPLY[@]}} -ge 1 ]] \
&& [[ "${{COMPREPLY[0]}}" == *= || "${{COMPREPLY[0]}}" == */ ]]; then
compopt -o nospace 2>/dev/null
fi
}}
complete -o nosort -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 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;
for &word in completed_for_max {
if let Some(child) = max_node.child(word) {
max_node = child;
} else {
break;
}
}
let max_level = max_level_of_children(max_node).max(1);
let tap_count = tap_detect(app_name, &input_key, max_level);
let candidates = complete_rotating(tree, &words, tap_count);
for candidate in candidates {
println!("{}", candidate);
}
true
}
fn tap_detect(app_name: &str, input_key: &str, max_level: u32) -> u32 {
use std::io::Write;
const ADVANCE_MS: u128 = 200;
let max = max_level.max(1);
let ppid = std::env::var("_COMP_SHELL_PID")
.or_else(|_| std::env::var("PPID"))
.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 mut tap_count = 1u32;
if let Ok(content) = std::fs::read_to_string(&tap_file) {
let mut parts = content.splitn(3, ' ');
let prev_time: u128 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let prev_count: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let prev_key = parts.next().unwrap_or("").trim_end();
if prev_key == cur_key && now_ms.saturating_sub(prev_time) < ADVANCE_MS {
tap_count = prev_count.saturating_add(1).min(max);
}
}
let to_persist = if tap_count >= max { 0 } else { tap_count };
if let Ok(mut f) = std::fs::File::create(&tap_file) {
let _ = write!(f, "{} {} {}", now_ms, to_persist, cur_key);
}
tap_count
}
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();
if let Node::Group { children, .. } = &tree.root {
for (name, node) in children {
if name == "help" || name.starts_with('-') {
continue;
}
match node {
Node::Group { children: sub, .. } if !sub.is_empty() => {
for sub_name in sub.keys() {
results.push(format!("{} {}", name, sub_name));
}
}
_ => {
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 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(&[])); }
}