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 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_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))
{
let values = provider(value_partial, remaining);
return values.into_iter()
.map(|v| format!("{key}={v}"))
.collect();
}
}
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
}
}
}
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()
.and_then(|p| {
let path = std::path::PathBuf::from(&p);
if path.components().count() > 1 {
std::env::current_dir().ok().map(|cwd| cwd.join(path))
} else {
Some(path)
}
})
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| app_name.to_string());
print!(r#"_{app}_complete() {{
COMP_WORDBREAKS="${{COMP_WORDBREAKS//:}}"
local line="${{COMP_LINE:0:$COMP_POINT}}"
local -a words=()
local word=""
local in_quote=""
local i=0
while [ $i -lt ${{#line}} ]; do
local ch="${{line:$i:1}}"
if [ -n "$in_quote" ]; then
if [ "$ch" = "$in_quote" ]; then
in_quote=""
else
word+="$ch"
fi
elif [ "$ch" = "'" ] || [ "$ch" = '"' ]; then
in_quote="$ch"
elif [ "$ch" = " " ] || [ "$ch" = $'\t' ]; then
if [ -n "$word" ]; then
words+=("$word")
word=""
fi
else
word+="$ch"
fi
i=$((i + 1))
done
words+=("$word")
local IFS=$'\n'
COMPREPLY=($({env_var}=bash _COMP_SHELL_PID=$$ "{completer}" -- "${{words[@]}}" 2>/dev/tty))
}}
if [[ "${{BASH_VERSINFO[0]}}" -eq 4 && "${{BASH_VERSINFO[1]}}" -ge 4 || "${{BASH_VERSINFO[0]}}" -gt 4 ]]; then
complete -o default -o bashdefault -o nosort -F _{app}_complete {app}
else
complete -o default -o bashdefault -F _{app}_complete {app}
fi
"#,
app = app_name,
env_var = env_var,
completer = completer,
);
}
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 args: Vec<String> = std::env::args().collect();
let words_start = args.iter().position(|a| a == "--").map(|i| i + 1).unwrap_or(1);
let words: Vec<&str> = args[words_start..].iter().map(|s| s.as_str()).collect();
let input_key = words[1..].join(" ");
let tap_count = tap_detect(app_name, &input_key);
let candidates = if tap_count >= 3 && tap_count % 2 == 1 {
let expanded = complete_expanded(tree, &words);
if !expanded.is_empty() {
expanded
} else {
complete_at_tap(tree, &words, tap_count)
}
} else {
complete_at_tap(tree, &words, tap_count)
};
for candidate in candidates {
println!("{}", candidate);
}
true
}
fn tap_detect(app_name: &str, input_key: &str) -> u32 {
use std::io::Write;
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 = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut count = 1u32;
if let Ok(content) = std::fs::read_to_string(&tap_file) {
let mut parts = content.splitn(3, ' ');
let prev_time: u64 = 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();
if prev_key == input_key && now.saturating_sub(prev_time) < 5 {
count = prev_count + 1;
}
}
if let Ok(mut f) = std::fs::File::create(&tap_file) {
let _ = write!(f, "{} {} {}", now, count, input_key);
}
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 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(&[])); }
}