#[cfg(feature = "fuzzy")]
use fuzzy_matcher::skim::SkimMatcherV2;
#[cfg(feature = "fuzzy")]
use fuzzy_matcher::FuzzyMatcher;
use thiserror::Error;
use crate::model::{Command, Example};
#[derive(Debug, Clone)]
pub struct CommandEntry<'a> {
pub path: Vec<String>,
pub command: &'a Command,
}
impl<'a> CommandEntry<'a> {
pub fn name(&self) -> &str {
self.path.last().map(String::as_str).unwrap_or("")
}
pub fn path_str(&self) -> String {
self.path.join(".")
}
}
#[derive(Debug, Error)]
pub enum QueryError {
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
}
pub struct Registry {
commands: Vec<Command>,
}
impl Registry {
pub fn new(commands: Vec<Command>) -> Self {
Self { commands }
}
pub(crate) fn push(&mut self, cmd: Command) {
self.commands.push(cmd);
}
pub fn commands(&self) -> &[Command] {
&self.commands
}
pub fn list_commands(&self) -> Vec<&Command> {
self.commands.iter().collect()
}
pub fn get_command(&self, canonical: &str) -> Option<&Command> {
self.commands.iter().find(|c| c.canonical == canonical)
}
pub fn get_subcommand(&self, path: &[&str]) -> Option<&Command> {
if path.is_empty() {
return None;
}
let mut current = self.get_command(path[0])?;
for &segment in &path[1..] {
current = current
.subcommands
.iter()
.find(|c| c.canonical == segment)?;
}
Some(current)
}
pub fn get_examples(&self, canonical: &str) -> Option<&[Example]> {
self.get_command(canonical).map(|c| c.examples.as_slice())
}
pub fn search(&self, query: &str) -> Vec<&Command> {
let q = query.to_lowercase();
self.commands
.iter()
.filter(|c| {
c.canonical.to_lowercase().contains(&q)
|| c.summary.to_lowercase().contains(&q)
|| c.description.to_lowercase().contains(&q)
})
.collect()
}
#[cfg(feature = "fuzzy")]
pub fn fuzzy_search(&self, query: &str) -> Vec<(&Command, i64)> {
let matcher = SkimMatcherV2::default();
let mut results: Vec<(&Command, i64)> = self
.commands
.iter()
.filter_map(|cmd| {
let text = format!("{} {} {}", cmd.canonical, cmd.summary, cmd.description);
matcher.fuzzy_match(&text, query).map(|score| (cmd, score))
})
.collect();
results.sort_by(|a, b| b.1.cmp(&a.1));
results
}
pub fn match_intent(&self, phrase: &str) -> Vec<(&Command, u32)> {
let phrase_lower = phrase.to_lowercase();
let words: Vec<&str> = phrase_lower
.split_whitespace()
.filter(|w| !w.is_empty())
.collect();
if words.is_empty() {
return vec![];
}
let mut results: Vec<(&Command, u32)> = self
.commands
.iter()
.filter_map(|cmd| {
let combined = format!(
"{} {} {} {} {}",
cmd.canonical.to_lowercase(),
cmd.aliases
.iter()
.map(|s| s.to_lowercase())
.collect::<Vec<_>>()
.join(" "),
cmd.semantic_aliases
.iter()
.map(|s| s.to_lowercase())
.collect::<Vec<_>>()
.join(" "),
cmd.summary.to_lowercase(),
cmd.description.to_lowercase(),
);
let score = words.iter().filter(|&&w| combined.contains(w)).count() as u32;
if score > 0 {
Some((cmd, score))
} else {
None
}
})
.collect();
results.sort_by(|a, b| b.1.cmp(&a.1));
results
}
pub fn to_json(&self) -> Result<String, QueryError> {
serde_json::to_string_pretty(&self.commands).map_err(QueryError::Serialization)
}
pub fn to_json_with_fields(&self, fields: &[&str]) -> Result<String, QueryError> {
if fields.is_empty() {
return self.to_json();
}
let value = serde_json::to_value(&self.commands).map_err(QueryError::Serialization)?;
let filtered = filter_commands_value(value, fields);
serde_json::to_string_pretty(&filtered).map_err(QueryError::Serialization)
}
pub fn to_ndjson(&self) -> Result<String, QueryError> {
self.to_ndjson_with_fields(&[])
}
pub fn to_ndjson_with_fields(&self, fields: &[&str]) -> Result<String, QueryError> {
let entries = self.iter_all_recursive();
if entries.is_empty() {
return Ok(String::new());
}
let mut lines = Vec::with_capacity(entries.len());
for entry in &entries {
let line = command_to_ndjson_with_fields(entry.command, fields)?;
lines.push(line);
}
let mut output = lines.join("\n");
output.push('\n');
Ok(output)
}
pub fn iter_all_recursive(&self) -> Vec<CommandEntry<'_>> {
let mut out = Vec::new();
for cmd in &self.commands {
collect_recursive(cmd, vec![], &mut out);
}
out
}
}
pub fn command_to_json_with_fields(cmd: &Command, fields: &[&str]) -> Result<String, QueryError> {
if fields.is_empty() {
return serde_json::to_string_pretty(cmd).map_err(QueryError::Serialization);
}
let value = serde_json::to_value(cmd).map_err(QueryError::Serialization)?;
let filtered = filter_command_object(value, fields);
serde_json::to_string_pretty(&filtered).map_err(QueryError::Serialization)
}
fn filter_commands_value(value: serde_json::Value, fields: &[&str]) -> serde_json::Value {
match value {
serde_json::Value::Array(arr) => {
serde_json::Value::Array(
arr.into_iter()
.map(|v| filter_command_object(v, fields))
.collect(),
)
}
other => other,
}
}
fn filter_command_object(value: serde_json::Value, fields: &[&str]) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut out = serde_json::Map::new();
for field in fields {
if let Some(v) = map.get(*field) {
let filtered_v = if *field == "subcommands" {
filter_commands_value(v.clone(), fields)
} else {
v.clone()
};
out.insert((*field).to_owned(), filtered_v);
}
}
serde_json::Value::Object(out)
}
other => other,
}
}
fn collect_recursive<'a>(cmd: &'a Command, mut path: Vec<String>, out: &mut Vec<CommandEntry<'a>>) {
path.push(cmd.canonical.clone());
out.push(CommandEntry {
path: path.clone(),
command: cmd,
});
for sub in &cmd.subcommands {
collect_recursive(sub, path.clone(), out);
}
}
pub fn command_to_ndjson(cmd: &Command) -> Result<String, QueryError> {
command_to_ndjson_with_fields(cmd, &[])
}
fn command_to_ndjson_with_fields(cmd: &Command, fields: &[&str]) -> Result<String, QueryError> {
let mut value = serde_json::to_value(cmd).map_err(QueryError::Serialization)?;
if !fields.is_empty() {
if let serde_json::Value::Object(ref mut map) = value {
let keys_to_remove: Vec<String> = map
.keys()
.filter(|k| !fields.contains(&k.as_str()))
.cloned()
.collect();
for key in keys_to_remove {
map.remove(&key);
}
}
}
serde_json::to_string(&value).map_err(QueryError::Serialization)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Command;
fn registry() -> Registry {
let sub = Command::builder("push")
.summary("Push changes")
.build()
.unwrap();
let remote = Command::builder("remote")
.summary("Manage remotes")
.subcommand(sub)
.build()
.unwrap();
let list = Command::builder("list")
.summary("List all items in the store")
.build()
.unwrap();
Registry::new(vec![remote, list])
}
#[test]
fn test_list_commands() {
let r = registry();
let cmds = r.list_commands();
assert_eq!(cmds.len(), 2);
}
#[test]
fn test_get_command() {
let r = registry();
assert!(r.get_command("remote").is_some());
assert!(r.get_command("missing").is_none());
}
#[test]
fn test_get_subcommand() {
let r = registry();
assert_eq!(
r.get_subcommand(&["remote", "push"]).unwrap().canonical,
"push"
);
assert!(r.get_subcommand(&["remote", "nope"]).is_none());
assert!(r.get_subcommand(&[]).is_none());
}
#[test]
fn test_get_examples_empty() {
let r = registry();
assert_eq!(r.get_examples("list"), Some([].as_slice()));
}
#[test]
fn test_search_match() {
let r = registry();
let results = r.search("store");
assert_eq!(results.len(), 1);
assert_eq!(results[0].canonical, "list");
}
#[test]
fn test_search_no_match() {
let r = registry();
assert!(r.search("zzz").is_empty());
}
#[cfg(feature = "fuzzy")]
#[test]
fn test_fuzzy_search_match() {
let r = registry();
let results = r.fuzzy_search("lst");
assert!(!results.is_empty());
assert!(results.iter().any(|(cmd, _)| cmd.canonical == "list"));
}
#[cfg(feature = "fuzzy")]
#[test]
fn test_fuzzy_search_no_match() {
let r = registry();
assert!(r.fuzzy_search("zzzzz").is_empty());
}
#[cfg(feature = "fuzzy")]
#[test]
fn test_fuzzy_search_sorted_by_score() {
let exact = Command::builder("list")
.summary("List all items")
.build()
.unwrap();
let weak = Command::builder("remote")
.summary("Manage remotes")
.build()
.unwrap();
let r = Registry::new(vec![weak, exact]);
let results = r.fuzzy_search("list");
assert!(!results.is_empty());
assert_eq!(results[0].0.canonical, "list");
for window in results.windows(2) {
assert!(window[0].1 >= window[1].1);
}
}
#[test]
fn test_to_json() {
let r = registry();
let json = r.to_json().unwrap();
assert!(json.contains("remote"));
assert!(json.contains("list"));
let _: serde_json::Value = serde_json::from_str(&json).unwrap();
}
#[test]
fn test_to_ndjson_empty_registry_returns_empty_string() {
let r = Registry::new(vec![]);
let ndjson = r.to_ndjson().unwrap();
assert_eq!(ndjson, "");
}
#[test]
fn test_to_ndjson_one_line_per_command_including_subcommands() {
let r = registry();
let ndjson = r.to_ndjson().unwrap();
assert!(ndjson.ends_with('\n'), "should end with trailing newline");
let lines: Vec<&str> = ndjson
.trim_end_matches('\n')
.split('\n')
.filter(|l| !l.is_empty())
.collect();
assert_eq!(lines.len(), 3, "remote + push + list = 3 lines, got: {:?}", lines);
}
#[test]
fn test_to_ndjson_each_line_is_valid_json() {
let r = registry();
let ndjson = r.to_ndjson().unwrap();
for line in ndjson.trim_end_matches('\n').split('\n') {
let result: Result<serde_json::Value, _> = serde_json::from_str(line);
assert!(result.is_ok(), "line is not valid JSON: {:?}", line);
}
}
#[test]
fn test_to_ndjson_depth_first_order() {
let r = registry();
let ndjson = r.to_ndjson().unwrap();
let lines: Vec<&str> = ndjson.trim_end_matches('\n').split('\n').collect();
let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
let third: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
assert_eq!(first["canonical"], "remote");
assert_eq!(second["canonical"], "push");
assert_eq!(third["canonical"], "list");
}
#[test]
fn test_to_ndjson_with_fields_filters_correctly() {
let r = registry();
let ndjson = r.to_ndjson_with_fields(&["canonical", "summary"]).unwrap();
for line in ndjson.trim_end_matches('\n').split('\n') {
let val: serde_json::Value = serde_json::from_str(line).unwrap();
assert!(val.get("canonical").is_some(), "missing 'canonical' in line: {}", line);
assert!(val.get("summary").is_some(), "missing 'summary' in line: {}", line);
assert!(
val.get("description").is_none(),
"'description' should be filtered out: {}",
line
);
assert!(
val.get("examples").is_none(),
"'examples' should be filtered out: {}",
line
);
}
}
#[test]
fn test_to_ndjson_with_empty_fields_includes_all() {
let r = registry();
let ndjson_all = r.to_ndjson().unwrap();
let ndjson_empty_fields = r.to_ndjson_with_fields(&[]).unwrap();
assert_eq!(ndjson_all, ndjson_empty_fields);
}
#[test]
fn test_command_to_ndjson_single_compact_line() {
let cmd = Command::builder("deploy").summary("Deploy").build().unwrap();
let line = super::command_to_ndjson(&cmd).unwrap();
assert!(
!line.ends_with('\n'),
"command_to_ndjson should have no trailing newline"
);
assert!(
!line.contains('\n'),
"command_to_ndjson should be a single line"
);
let val: serde_json::Value = serde_json::from_str(&line).unwrap();
assert_eq!(val["canonical"], "deploy");
assert_eq!(val["summary"], "Deploy");
}
#[test]
fn test_command_to_ndjson_compact_not_pretty() {
let cmd = Command::builder("run").build().unwrap();
let line = super::command_to_ndjson(&cmd).unwrap();
assert!(!line.contains('\n'));
assert!(!line.contains(": "), "should be compact, not pretty-printed");
}
#[test]
fn test_to_json_with_fields_filters_keys() {
let r = Registry::new(vec![
Command::builder("deploy")
.summary("Deploy the app")
.description("Deploys to production")
.build()
.unwrap(),
]);
let json = r.to_json_with_fields(&["canonical", "summary"]).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
let obj = &v[0];
assert_eq!(obj["canonical"], "deploy");
assert_eq!(obj["summary"], "Deploy the app");
assert!(obj.get("description").is_none());
assert!(obj.get("examples").is_none());
assert!(obj.get("aliases").is_none());
}
#[test]
fn test_to_json_with_fields_empty_falls_back_to_full() {
let r = Registry::new(vec![
Command::builder("deploy")
.summary("Deploy the app")
.build()
.unwrap(),
]);
let full = r.to_json().unwrap();
let filtered = r.to_json_with_fields(&[]).unwrap();
assert_eq!(full, filtered);
}
#[test]
fn test_to_json_with_fields_missing_field_silently_omitted() {
let r = Registry::new(vec![
Command::builder("deploy").build().unwrap(),
]);
let json = r
.to_json_with_fields(&["canonical", "nonexistent_key"])
.unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
let obj = &v[0];
assert_eq!(obj["canonical"], "deploy");
assert!(obj.get("nonexistent_key").is_none());
}
#[test]
fn test_to_json_with_fields_subcommands_filtered_recursively() {
let r = Registry::new(vec![
Command::builder("remote")
.summary("Manage remotes")
.subcommand(
Command::builder("add")
.summary("Add a remote")
.description("Detailed add docs")
.build()
.unwrap(),
)
.build()
.unwrap(),
]);
let json = r
.to_json_with_fields(&["canonical", "summary", "subcommands"])
.unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
let obj = &v[0];
assert_eq!(obj["canonical"], "remote");
assert!(obj.get("description").is_none());
let subs = obj["subcommands"].as_array().unwrap();
assert_eq!(subs.len(), 1);
assert_eq!(subs[0]["canonical"], "add");
assert_eq!(subs[0]["summary"], "Add a remote");
assert!(subs[0].get("description").is_none());
}
#[test]
fn test_to_json_with_fields_subcommands_not_requested_absent() {
let r = Registry::new(vec![
Command::builder("remote")
.subcommand(Command::builder("add").build().unwrap())
.build()
.unwrap(),
]);
let json = r.to_json_with_fields(&["canonical"]).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
let obj = &v[0];
assert_eq!(obj["canonical"], "remote");
assert!(obj.get("subcommands").is_none());
}
#[test]
fn test_command_to_json_with_fields() {
let cmd = Command::builder("deploy")
.summary("Deploy the app")
.description("Long description")
.build()
.unwrap();
let json = command_to_json_with_fields(&cmd, &["canonical", "summary"]).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["canonical"], "deploy");
assert_eq!(v["summary"], "Deploy the app");
assert!(v.get("description").is_none());
}
#[test]
fn test_command_to_json_with_fields_empty_falls_back_to_full() {
let cmd = Command::builder("deploy")
.summary("Deploy the app")
.build()
.unwrap();
let full = serde_json::to_string_pretty(&cmd).unwrap();
let filtered = command_to_json_with_fields(&cmd, &[]).unwrap();
assert_eq!(full, filtered);
}
#[test]
fn test_match_intent_single_word() {
let r = Registry::new(vec![
Command::builder("deploy")
.summary("Deploy a service")
.build()
.unwrap(),
Command::builder("status")
.summary("Check service status")
.build()
.unwrap(),
]);
let results = r.match_intent("deploy");
assert!(!results.is_empty());
assert_eq!(results[0].0.canonical, "deploy");
}
#[test]
fn test_match_intent_phrase() {
let r = Registry::new(vec![
Command::builder("deploy")
.summary("Deploy a service to an environment")
.semantic_alias("release to production")
.semantic_alias("push to environment")
.build()
.unwrap(),
Command::builder("status")
.summary("Check service status")
.build()
.unwrap(),
]);
let results = r.match_intent("release to production");
assert!(!results.is_empty());
assert_eq!(results[0].0.canonical, "deploy");
}
#[test]
fn test_match_intent_no_match() {
let r = Registry::new(vec![Command::builder("deploy")
.summary("Deploy a service")
.build()
.unwrap()]);
let results = r.match_intent("zzz xyzzy foobar");
assert!(results.is_empty());
}
#[test]
fn test_match_intent_sorted_by_score() {
let r = Registry::new(vec![
Command::builder("status")
.summary("Check service status")
.build()
.unwrap(),
Command::builder("deploy")
.summary("Deploy a service to an environment")
.semantic_alias("release to production")
.semantic_alias("push to environment")
.build()
.unwrap(),
]);
let results = r.match_intent("deploy to production");
assert!(!results.is_empty());
assert_eq!(results[0].0.canonical, "deploy");
for window in results.windows(2) {
assert!(window[0].1 >= window[1].1);
}
}
#[test]
fn test_iter_all_recursive_flat() {
let r = Registry::new(vec![
Command::builder("a").build().unwrap(),
Command::builder("b").build().unwrap(),
]);
let entries = r.iter_all_recursive();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].path_str(), "a");
assert_eq!(entries[1].path_str(), "b");
}
#[test]
fn test_iter_all_recursive_nested() {
let registry = Registry::new(vec![
Command::builder("remote")
.subcommand(Command::builder("add").build().unwrap())
.subcommand(Command::builder("remove").build().unwrap())
.build()
.unwrap(),
Command::builder("status").build().unwrap(),
]);
let names: Vec<String> = registry
.iter_all_recursive()
.iter()
.map(|e| e.path_str())
.collect();
assert_eq!(names, ["remote", "remote.add", "remote.remove", "status"]);
}
#[test]
fn test_iter_all_recursive_deep_nesting() {
let leaf = Command::builder("blue-green").build().unwrap();
let mid = Command::builder("strategy")
.subcommand(leaf)
.build()
.unwrap();
let top = Command::builder("deploy").subcommand(mid).build().unwrap();
let r = Registry::new(vec![top]);
let names: Vec<String> = r
.iter_all_recursive()
.iter()
.map(|e| e.path_str())
.collect();
assert_eq!(
names,
["deploy", "deploy.strategy", "deploy.strategy.blue-green"]
);
}
#[test]
fn test_iter_all_recursive_entry_helpers() {
let registry = Registry::new(vec![Command::builder("remote")
.subcommand(Command::builder("add").build().unwrap())
.build()
.unwrap()]);
let entries = registry.iter_all_recursive();
assert_eq!(entries[1].name(), "add");
assert_eq!(entries[1].path, vec!["remote", "add"]);
assert_eq!(entries[1].path_str(), "remote.add");
}
#[test]
fn test_iter_all_recursive_empty() {
let r = Registry::new(vec![]);
assert!(r.iter_all_recursive().is_empty());
}
}
#[cfg(test)]
#[cfg(feature = "fuzzy")]
mod fuzzy_tests {
use super::*;
use crate::model::Command;
#[test]
fn test_fuzzy_search_returns_matches() {
let r = Registry::new(vec![
Command::builder("deploy").build().unwrap(),
Command::builder("delete").build().unwrap(),
Command::builder("status").build().unwrap(),
]);
let results = r.fuzzy_search("dep");
assert!(!results.is_empty(), "should find matches for 'dep'");
assert_eq!(results[0].0.canonical, "deploy");
}
#[test]
fn test_fuzzy_search_sorted_by_score_descending() {
let r = Registry::new(vec![
Command::builder("deploy").build().unwrap(),
Command::builder("delete").build().unwrap(),
]);
let results = r.fuzzy_search("deploy");
assert!(!results.is_empty());
for i in 1..results.len() {
assert!(
results[i - 1].1 >= results[i].1,
"results should be sorted by score desc"
);
}
}
#[test]
fn test_fuzzy_search_no_match_returns_empty() {
let r = Registry::new(vec![Command::builder("run").build().unwrap()]);
let results = r.fuzzy_search("zzzzzzz");
if !results.is_empty() {
assert!(results.iter().all(|(_, score)| *score > 0));
}
}
#[test]
fn test_fuzzy_search_score_type() {
let r = Registry::new(vec![Command::builder("deploy").build().unwrap()]);
let results = r.fuzzy_search("deploy");
assert!(!results.is_empty());
let score: i64 = results[0].1;
assert!(score > 0);
}
}