use std::cmp::max;
use crate::contracts::known_bijux_tools;
use crate::routing::model::{CLI_CONFIG_SUBCOMMANDS, CLI_PLUGINS_SUBCOMMANDS, CLI_ROOT_ALIASES};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct RouteCorrection {
pub(super) nearest_command: String,
pub(super) next_command: String,
pub(super) next_help: String,
}
const ROOT_COMMANDS: &[&str] = &[
"apps",
"cli",
"audit",
"completion",
"config",
"docs",
"doctor",
"help",
"history",
"inspect",
"install",
"memory",
"plugins",
"repl",
"status",
"version",
];
const CLI_COMMANDS: &[&str] = &["status", "paths", "config", "self-test", "plugins"];
pub(super) fn correction_for_unknown_route(path: &[String]) -> Option<RouteCorrection> {
let suggested = suggest_path(path)?;
let joined = suggested.join(" ");
Some(RouteCorrection {
nearest_command: joined.clone(),
next_command: format!("bijux {joined}"),
next_help: format!("bijux help {joined}"),
})
}
fn suggest_path(path: &[String]) -> Option<Vec<String>> {
let [first, rest @ ..] = path else {
return None;
};
match first.as_str() {
"config" => suggest_tail("config", rest, CLI_CONFIG_SUBCOMMANDS, "config"),
"plugins" => suggest_tail("plugins", rest, CLI_PLUGINS_SUBCOMMANDS, "plugins"),
"cli" => suggest_cli(rest),
known if ROOT_COMMANDS.contains(&known) => Some(path.to_vec()),
_ => {
let best_root = nearest_root(first)?;
let mut out = vec![best_root];
out.extend(rest.iter().cloned());
Some(out)
}
}
}
fn nearest_root(query: &str) -> Option<String> {
let mut best: Option<(usize, usize, usize, String)> = None;
for root in ROOT_COMMANDS {
let score = similarity_score(&query.to_ascii_lowercase(), root);
let candidate = (score, root.len(), 0usize, (*root).to_string());
if best.as_ref().is_none_or(|existing| {
candidate.0 > existing.0 || (candidate.0 == existing.0 && candidate.1 > existing.1)
}) {
best = Some(candidate);
}
}
for tool in known_bijux_tools() {
let canonical_score = similarity_score(&query.to_ascii_lowercase(), tool.namespace);
let canonical = (canonical_score, tool.namespace.len(), 0usize, tool.namespace.to_string());
if best.as_ref().is_none_or(|existing| {
canonical.0 > existing.0 || (canonical.0 == existing.0 && canonical.1 > existing.1)
}) {
best = Some(canonical);
}
for alias in tool.aliases {
let score = similarity_score(&query.to_ascii_lowercase(), alias);
let candidate = (score, alias.len(), 1usize, tool.namespace.to_string());
if best.as_ref().is_none_or(|existing| {
candidate.0 > existing.0
|| (candidate.0 == existing.0
&& (candidate.2 < existing.2
|| (candidate.2 == existing.2 && candidate.1 > existing.1)))
}) {
best = Some(candidate);
}
}
}
best.map(|(_, _, _, canonical)| canonical)
}
fn suggest_cli(rest: &[String]) -> Option<Vec<String>> {
let [second, tail @ ..] = rest else {
return Some(vec!["cli".to_string()]);
};
match second.as_str() {
"config" => suggest_tail("cli", tail, CLI_CONFIG_SUBCOMMANDS, "config"),
"plugins" => suggest_tail("cli", tail, CLI_PLUGINS_SUBCOMMANDS, "plugins"),
known if CLI_COMMANDS.contains(&known) || CLI_ROOT_ALIASES.contains(&known) => {
Some(vec!["cli".to_string(), known.to_string()])
}
_ => {
let mut candidates = CLI_COMMANDS.to_vec();
candidates.extend_from_slice(CLI_ROOT_ALIASES);
let best = nearest(second, &candidates)?;
Some(vec!["cli".to_string(), best.to_string()])
}
}
}
fn suggest_tail(
root: &str,
rest: &[String],
candidates: &[&str],
namespace: &str,
) -> Option<Vec<String>> {
let [second, _] = rest else {
return match root {
"cli" => Some(vec!["cli".to_string(), namespace.to_string()]),
_ => Some(vec![namespace.to_string()]),
};
};
let best = nearest(second, candidates)?;
if root == "cli" {
Some(vec!["cli".to_string(), namespace.to_string(), best.to_string()])
} else {
Some(vec![namespace.to_string(), best.to_string()])
}
}
fn nearest<'a>(query: &str, candidates: &'a [&'a str]) -> Option<&'a str> {
candidates
.iter()
.copied()
.max_by_key(|candidate| similarity_score(&query.to_ascii_lowercase(), candidate))
}
fn similarity_score(left: &str, right: &str) -> usize {
let right = right.to_ascii_lowercase();
let prefix = common_prefix_len(left, &right);
let distance = levenshtein_distance(left, &right);
let normalized = max(left.chars().count(), right.chars().count());
(prefix * 1000) + normalized.saturating_sub(distance)
}
fn common_prefix_len(left: &str, right: &str) -> usize {
left.chars().zip(right.chars()).take_while(|(a, b)| a == b).count()
}
fn levenshtein_distance(left: &str, right: &str) -> usize {
let l: Vec<char> = left.chars().collect();
let r: Vec<char> = right.chars().collect();
if l.is_empty() {
return r.len();
}
if r.is_empty() {
return l.len();
}
let mut prev: Vec<usize> = (0..=r.len()).collect();
let mut curr = vec![0; r.len() + 1];
for (i, lc) in l.iter().enumerate() {
curr[0] = i + 1;
for (j, rc) in r.iter().enumerate() {
let cost = usize::from(lc != rc);
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
}
prev.clone_from(&curr);
}
prev[r.len()]
}