Skip to main content

saya_cli/
slash.rs

1use saya_agent::ApprovalPolicy;
2use std::{fmt, str::FromStr};
3
4/// Known slash command names handled by `parse_slash_command`.
5const 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
119/// Calculates the Levenshtein edit distance between two strings using a single rolling row.
120fn 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
138/// Returns the known command with the smallest Levenshtein distance to `input` if distance <= 2.
139fn 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
179/// Returns a short usage and example string for a known slash command, or `None` if unknown.
180pub 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
232/// Returns command-specific help for a topic, or general help text if `topic` is `None`.
233pub 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}