1use saya_agent::ApprovalPolicy;
2use std::{fmt, str::FromStr};
3
4const KNOWN_COMMANDS: &[&str] = &[
6 "connect",
7 "connections",
8 "include",
9 "exclude",
10 "provider",
11 "model",
12 "privacy",
13 "approvals",
14 "schema",
15 "sql",
16 "export",
17 "chart",
18 "explain",
19 "clear",
20 "history",
21 "sessions",
22 "resume",
23 "help",
24 "exit",
25 "quit",
26];
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum SlashCommand {
30 Connect(String),
31 Connections,
32 Include(String),
33 Exclude(String),
34 Provider(Option<String>),
35 Model(Option<String>),
36 Privacy(Option<bool>),
37 Approvals(Option<ApprovalPolicy>),
38 Schema(bool),
39 Sql(String),
40 Export(String),
41 Chart(String),
42 Explain(String),
43 Clear,
44 History,
45 Sessions,
46 Resume(String),
47 Help(Option<String>),
48 Exit,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct SlashParseError(pub String);
53
54impl fmt::Display for SlashParseError {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 f.write_str(&self.0)
57 }
58}
59impl std::error::Error for SlashParseError {}
60
61pub fn parse_slash_command(input: &str) -> Result<Option<SlashCommand>, SlashParseError> {
62 let trimmed = input.trim();
63 if !trimmed.starts_with('/') {
64 return Ok(None);
65 }
66 let mut parts = trimmed[1..].split_whitespace();
67 let name = parts.next().unwrap_or_default();
68 let arg = parts.collect::<Vec<_>>().join(" ");
69 let required = || {
70 (!arg.is_empty())
71 .then_some(arg.clone())
72 .ok_or_else(|| SlashParseError("command requires an argument".into()))
73 };
74 let command = match name {
75 "connect" => SlashCommand::Connect(required()?),
76 "connections" => SlashCommand::Connections,
77 "include" => SlashCommand::Include(required()?),
78 "exclude" => SlashCommand::Exclude(required()?),
79 "provider" => SlashCommand::Provider((!arg.is_empty()).then_some(arg)),
80 "model" => SlashCommand::Model((!arg.is_empty()).then_some(arg)),
81 "privacy" => SlashCommand::Privacy(parse_bool(&arg)?),
82 "approvals" => SlashCommand::Approvals(parse_approval(&arg)?),
83 "schema" => SlashCommand::Schema(arg == "refresh"),
84 "sql" => {
85 let query = trimmed.strip_prefix("/sql").unwrap_or("").trim();
86 if query.is_empty() {
87 return Err(SlashParseError("sql requires a query".into()));
88 }
89 SlashCommand::Sql(query.to_string())
90 }
91 "export" => {
92 let path = trimmed.strip_prefix("/export").unwrap_or("").trim();
93 if path.is_empty() {
94 return Err(SlashParseError(
95 "export requires a file path, e.g. /export out.csv".into(),
96 ));
97 }
98 SlashCommand::Export(path.to_string())
99 }
100 "chart" => SlashCommand::Chart(arg.trim().to_string()),
101 "explain" => SlashCommand::Explain(arg.trim().to_string()),
102 "clear" => SlashCommand::Clear,
103 "history" => SlashCommand::History,
104 "sessions" => SlashCommand::Sessions,
105 "resume" => SlashCommand::Resume(required()?),
106 "help" => SlashCommand::Help((!arg.is_empty()).then_some(arg)),
107 "exit" | "quit" => SlashCommand::Exit,
108 other => {
109 let msg = match closest_command(other) {
110 Some(sugg) => format!("unknown command: /{other} (did you mean /{sugg}?)"),
111 None => format!("unknown command: /{other}"),
112 };
113 return Err(SlashParseError(msg));
114 }
115 };
116 Ok(Some(command))
117}
118
119fn levenshtein(a: &str, b: &str) -> usize {
121 let b_chars: Vec<char> = b.chars().collect();
122 let mut row: Vec<usize> = (0..=b_chars.len()).collect();
123
124 for (i, ca) in a.chars().enumerate() {
125 let mut prev = row[0];
126 row[0] = i + 1;
127 for (j, &cb) in b_chars.iter().enumerate() {
128 let old_row_j_plus_1 = row[j + 1];
129 let cost = if ca == cb { 0 } else { 1 };
130 row[j + 1] = (prev + cost).min(row[j] + 1).min(old_row_j_plus_1 + 1);
131 prev = old_row_j_plus_1;
132 }
133 }
134
135 row.last().copied().unwrap_or(0)
136}
137
138fn closest_command(input: &str) -> Option<&'static str> {
140 let input_lower = input.to_lowercase();
141 let mut best_cmd = None;
142 let mut min_dist = usize::MAX;
143
144 for &cmd in KNOWN_COMMANDS {
145 let dist = levenshtein(&input_lower, cmd);
146 if dist < min_dist {
147 min_dist = dist;
148 best_cmd = Some(cmd);
149 }
150 }
151
152 if min_dist <= 2 { best_cmd } else { None }
153}
154
155fn parse_bool(value: &str) -> Result<Option<bool>, SlashParseError> {
156 if value.is_empty() {
157 return Ok(None);
158 }
159 match value {
160 "on" | "true" | "enable" => Ok(Some(true)),
161 "off" | "false" | "disable" => Ok(Some(false)),
162 _ => Err(SlashParseError("privacy expects on or off".into())),
163 }
164}
165
166fn parse_approval(value: &str) -> Result<Option<ApprovalPolicy>, SlashParseError> {
167 if value.is_empty() {
168 return Ok(None);
169 }
170 ApprovalPolicy::from_str(value)
171 .map(Some)
172 .map_err(|error| SlashParseError(error.to_string()))
173}
174
175pub fn help_text() -> &'static str {
176 "/connect <profile> /connections /include <profile> /exclude <profile>\n/provider [name] /model [name] /privacy [on|off]\n/approvals [ask|read-only|never] /schema [refresh] /sql <query> /export <path>\n/explain [sql] /clear /history /sessions /resume <id> /help /exit"
177}
178
179pub fn command_help(name: &str) -> Option<&'static str> {
181 let clean_name = name.trim_start_matches('/').to_lowercase();
182 match clean_name.as_str() {
183 "connect" => {
184 Some("connect <profile> — set the active database profile. Example: /connect prod")
185 }
186 "connections" => Some(
187 "connections — list configured database connection profiles. Example: /connections",
188 ),
189 "include" => Some(
190 "include <profile> — include an additional database profile. Example: /include staging",
191 ),
192 "exclude" => {
193 Some("exclude <profile> — exclude a database profile. Example: /exclude staging")
194 }
195 "provider" => {
196 Some("provider [name] — view or set the AI provider. Example: /provider anthropic")
197 }
198 "model" => Some("model [name] — view or set the AI model. Example: /model gpt-4o"),
199 "privacy" => {
200 Some("privacy [on|off] — view or toggle cloud data sharing. Example: /privacy off")
201 }
202 "approvals" => Some(
203 "approvals [ask|read-only|never] — view or set tool execution approval policy. Example: /approvals ask",
204 ),
205 "schema" => Some(
206 "schema [refresh] — display or refresh database schema context. Example: /schema refresh",
207 ),
208 "sql" => Some(
209 "sql <query> — execute a raw SQL query directly. Example: /sql SELECT * FROM users LIMIT 10;",
210 ),
211 "export" => Some(
212 "export <path> — write the last query's rows to a .csv or .json file. Example: /export results.csv",
213 ),
214 "chart" => Some(
215 "chart [type] [path] — render the last query as an interactive HTML chart and open it. type: bar|line|area|pie|doughnut|scatter (default auto)",
216 ),
217 "explain" => Some(
218 "explain [sql] — show the query plan (EXPLAIN) for the given SQL, or the last query if omitted",
219 ),
220 "clear" => Some("clear — clear conversation history and context. Example: /clear"),
221 "history" => Some("history — display session history. Example: /history"),
222 "sessions" => Some("sessions — list available interactive sessions. Example: /sessions"),
223 "resume" => Some("resume <id> — resume a previous session by ID. Example: /resume 12345"),
224 "help" => Some(
225 "help [command] — display general help or detailed usage for a command. Example: /help connect",
226 ),
227 "exit" | "quit" => Some("exit — exit the interactive CLI session. Example: /exit"),
228 _ => None,
229 }
230}
231
232pub fn help_for(topic: Option<&str>) -> String {
234 match topic {
235 Some(name) => {
236 let clean = name.trim_start_matches('/');
237 match command_help(clean) {
238 Some(help) => help.to_string(),
239 None => format!("No help for /{clean}. Type /help for the full list."),
240 }
241 }
242 None => help_text().to_string(),
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn test_help_command() {
252 assert_eq!(
253 parse_slash_command("/help"),
254 Ok(Some(SlashCommand::Help(None)))
255 );
256 assert_eq!(
257 parse_slash_command("/help connect"),
258 Ok(Some(SlashCommand::Help(Some("connect".into()))))
259 );
260
261 let help_connect = help_for(Some("connect"));
262 assert!(help_connect.contains("connect"));
263 assert!(help_connect.contains("Example"));
264
265 let help_unknown = help_for(Some("nope"));
266 assert!(help_unknown.contains("No help"));
267
268 assert_eq!(help_for(None), help_text().to_string());
269 }
270
271 #[test]
272 fn test_parse_sessions_and_resume() {
273 assert_eq!(
274 parse_slash_command("/sessions"),
275 Ok(Some(SlashCommand::Sessions))
276 );
277 assert_eq!(
278 parse_slash_command("/resume 12345"),
279 Ok(Some(SlashCommand::Resume("12345".into())))
280 );
281 assert_eq!(
282 parse_slash_command("/resume"),
283 Err(SlashParseError("command requires an argument".into()))
284 );
285 }
286
287 #[test]
288 fn test_parse_sql_command() {
289 assert_eq!(
290 parse_slash_command("/sql SELECT * FROM users;"),
291 Ok(Some(SlashCommand::Sql("SELECT * FROM users;".into())))
292 );
293 assert_eq!(
294 parse_slash_command("/sql SELECT a, b FROM table "),
295 Ok(Some(SlashCommand::Sql("SELECT a, b FROM table".into())))
296 );
297 assert_eq!(
298 parse_slash_command("/sql"),
299 Err(SlashParseError("sql requires a query".into()))
300 );
301 assert_eq!(
302 parse_slash_command("/sql "),
303 Err(SlashParseError("sql requires a query".into()))
304 );
305 }
306
307 #[test]
308 fn test_parse_export_command() {
309 assert_eq!(
310 parse_slash_command("/export out.csv"),
311 Ok(Some(SlashCommand::Export("out.csv".into())))
312 );
313 assert_eq!(
314 parse_slash_command("/export"),
315 Err(SlashParseError(
316 "export requires a file path, e.g. /export out.csv".into()
317 ))
318 );
319 }
320
321 #[test]
322 fn test_parse_chart_command() {
323 assert_eq!(
324 parse_slash_command("/chart"),
325 Ok(Some(SlashCommand::Chart("".into())))
326 );
327 assert_eq!(
328 parse_slash_command("/chart foo"),
329 Ok(Some(SlashCommand::Chart("foo".into())))
330 );
331 }
332
333 #[test]
334 fn test_parse_explain_command() {
335 assert_eq!(
336 parse_slash_command("/explain"),
337 Ok(Some(SlashCommand::Explain("".into())))
338 );
339 assert_eq!(
340 parse_slash_command("/explain SELECT 1"),
341 Ok(Some(SlashCommand::Explain("SELECT 1".into())))
342 );
343 }
344
345 #[test]
346 fn test_unknown_command_suggestion() {
347 let err = parse_slash_command("/conect prod").unwrap_err();
348 assert!(
349 err.0.contains("did you mean /connect"),
350 "expected suggestion in error message, got: {}",
351 err.0
352 );
353
354 let err = parse_slash_command("/zzzzzzzz").unwrap_err();
355 assert!(
356 !err.0.contains("did you mean"),
357 "unexpected suggestion in error message, got: {}",
358 err.0
359 );
360
361 assert_eq!(
362 parse_slash_command("/connect prod"),
363 Ok(Some(SlashCommand::Connect("prod".into())))
364 );
365 }
366}