#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Candidate {
pub(crate) value: String,
pub(crate) description: Option<String>,
}
const DESCRIPTIONS: &[(&str, &str)] = &[
("connect", "Connect to a database profile"),
("connections", "List configured database connections"),
("include", "Include a database profile in query scope"),
("exclude", "Exclude a database profile from query scope"),
("provider", "Set or view the AI provider"),
("model", "Set or view the AI model"),
("privacy", "Enable or disable data sharing privacy"),
("approvals", "Set approval policy for tool execution"),
("schema", "Inspect or refresh database schema"),
("doctor", "Diagnose config: secrets, provider endpoint"),
("sql", "Run a raw SQL query against the active profile"),
("export", "Export the last query result as CSV or JSON"),
("chart", "Render the last query as an HTML chart"),
("explain", "Explain the given or last SQL statement"),
("clear", "Clear current session context"),
("history", "Show saved sessions"),
("sessions", "List saved sessions"),
("resume", "Resume a saved session by id"),
(
"contracts",
"List contracts, or show one: /contracts [catalog.schema.object]",
),
("contract", "Alias for /contracts"),
("remember", "Store a confirmed contract claim"),
("forget", "Tombstone a contract claim so recall excludes it"),
("queue", "Show pending candidate claims awaiting review"),
("confirm", "Confirm a pending candidate claim by id prefix"),
("reject", "Reject a pending candidate claim by id prefix"),
("help", "Show help for slash commands"),
("exit", "Exit the REPL"),
("quit", "Exit the REPL"),
];
fn description_for(name: &str) -> Option<&'static str> {
DESCRIPTIONS
.iter()
.find(|(candidate, _)| *candidate == name)
.map(|(_, description)| *description)
}
#[allow(dead_code)]
pub(crate) fn slash_candidates(
line: &str,
profiles: &[String],
) -> Option<(usize, usize, Vec<Candidate>)> {
if !line.starts_with('/') {
return None;
}
let total_chars = line.chars().count();
if let Some(byte_idx) = line.rfind(' ') {
let cmd_word = line[1..]
.split_whitespace()
.next()
.unwrap_or_default()
.to_lowercase();
let choices: Vec<&str> = match cmd_word.as_str() {
"connect" | "include" | "exclude" => profiles.iter().map(String::as_str).collect(),
"provider" => vec![
"ollama",
"openai",
"openai_compatible",
"anthropic",
"gemini",
],
"approvals" => vec!["ask", "read-only", "never"],
"privacy" => vec!["on", "off"],
_ => return None,
};
let arg = &line[byte_idx + 1..];
let mut scored: Vec<(i32, Candidate)> = choices
.into_iter()
.filter_map(|val| {
super::fuzzy::fuzzy_score(val, arg).map(|score| {
(
score,
Candidate {
value: val.to_string(),
description: None,
},
)
})
})
.collect();
if scored.is_empty() {
return None;
}
scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
let candidates = scored.into_iter().map(|(_, candidate)| candidate).collect();
let start_char = line[..byte_idx].chars().count() + 1;
Some((start_char, total_chars, candidates))
} else {
let prefix = &line[1..];
let mut scored: Vec<(i32, Candidate)> = crate::slash::registry::KNOWN_COMMANDS
.iter()
.filter_map(|name| {
let description = description_for(name)?;
super::fuzzy::fuzzy_score(name, prefix).map(|score| {
(
score,
Candidate {
value: format!("/{name}"),
description: Some(description.to_string()),
},
)
})
})
.collect();
if scored.is_empty() {
return None;
}
scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
let candidates = scored.into_iter().map(|(_, candidate)| candidate).collect();
Some((0, total_chars, candidates))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn profiles() -> Vec<String> {
vec!["dev".to_string(), "prod".to_string()]
}
#[test]
fn descriptions_cover_exactly_the_registry() {
assert_eq!(
DESCRIPTIONS.len(),
crate::slash::registry::KNOWN_COMMANDS.len()
);
for (name, _) in DESCRIPTIONS {
assert!(
crate::slash::registry::KNOWN_COMMANDS.contains(name),
"{name} described but not registered"
);
}
for name in crate::slash::registry::KNOWN_COMMANDS {
assert!(
description_for(name).is_some(),
"{name} registered but not described"
);
}
}
#[test]
fn test_non_slash_line() {
assert_eq!(slash_candidates("hello", &profiles()), None);
}
#[test]
fn test_slash_only() {
let (start, end, candidates) = slash_candidates("/", &profiles()).unwrap();
assert_eq!((start, end), (0, 1));
assert_eq!(candidates.len(), 28);
assert_eq!(candidates[0].value, "/connect");
assert_eq!(
candidates[0].description.as_deref(),
Some("Connect to a database profile")
);
}
#[test]
fn test_command_prefix() {
let (start, end, candidates) = slash_candidates("/co", &profiles()).unwrap();
assert_eq!((start, end), (0, 3));
let values: Vec<_> = candidates.iter().map(|c| c.value.as_str()).collect();
assert_eq!(
values,
vec![
"/connect",
"/connections",
"/contracts",
"/contract",
"/confirm",
"/doctor"
]
);
}
#[test]
fn test_command_prefix_case_insensitive() {
let (start, end, candidates) = slash_candidates("/CO", &profiles()).unwrap();
assert_eq!((start, end), (0, 3));
let values: Vec<_> = candidates.iter().map(|c| c.value.as_str()).collect();
assert_eq!(
values,
vec![
"/connect",
"/connections",
"/contracts",
"/contract",
"/confirm",
"/doctor"
]
);
}
#[test]
fn test_profile_arguments() {
let (start, end, candidates) = slash_candidates("/connect ", &profiles()).unwrap();
assert_eq!((start, end), (9, 9));
let values: Vec<_> = candidates.iter().map(|c| c.value.as_str()).collect();
assert_eq!(values, vec!["dev", "prod"]);
assert_eq!(candidates[0].description, None);
let (start, end, candidates_p) = slash_candidates("/connect p", &profiles()).unwrap();
assert_eq!((start, end), (9, 10));
let values_p: Vec<_> = candidates_p.iter().map(|c| c.value.as_str()).collect();
assert_eq!(values_p, vec!["prod"]);
}
#[test]
fn test_provider_arguments() {
let (start, end, candidates) = slash_candidates("/provider op", &profiles()).unwrap();
assert_eq!((start, end), (10, 12));
let values: Vec<_> = candidates.iter().map(|c| c.value.as_str()).collect();
assert_eq!(&values[..2], &["openai", "openai_compatible"]);
}
#[test]
fn test_approvals_arguments() {
let (start, end, candidates) = slash_candidates("/approvals ", &profiles()).unwrap();
assert_eq!((start, end), (11, 11));
let values: Vec<_> = candidates.iter().map(|c| c.value.as_str()).collect();
assert_eq!(values, vec!["ask", "read-only", "never"]);
}
#[test]
fn test_privacy_arguments() {
let (start, end, candidates) = slash_candidates("/privacy o", &profiles()).unwrap();
assert_eq!((start, end), (9, 10));
let values: Vec<_> = candidates.iter().map(|c| c.value.as_str()).collect();
assert_eq!(values, vec!["on", "off"]);
}
#[test]
fn test_other_command_arguments() {
assert_eq!(slash_candidates("/clear x", &profiles()), None);
}
#[test]
fn test_multibyte_prefix() {
let unicode_profiles = vec!["🦀dev".to_string(), "prod".to_string()];
let (start, end, candidates) = slash_candidates("/connect 🦀", &unicode_profiles).unwrap();
assert_eq!((start, end), (9, 10));
let values: Vec<_> = candidates.iter().map(|c| c.value.as_str()).collect();
assert_eq!(values, vec!["🦀dev"]);
assert_eq!(slash_candidates("/connect 🚀", &unicode_profiles), None);
}
}