Skip to main content

saya_cli/
slash.rs

1use crate::cli::ContractsCommand;
2use saya_agent::ApprovalPolicy;
3use std::{fmt, str::FromStr};
4
5mod contracts;
6mod help;
7mod registry;
8
9// Re-exported so the session command layer's `crate::slash::help_for` path
10// still resolves after the help text moved to `help.rs`.
11pub(crate) use help::help_for;
12// The inline `test_help_command` test calls `help_text` bare via `super::*`;
13// bring it into scope for tests only so the test stays unchanged.
14#[cfg(test)]
15use help::help_text;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum SlashCommand {
19    Connect(String),
20    Connections,
21    Include(String),
22    Exclude(String),
23    Provider(Option<String>),
24    Model(Option<String>),
25    Privacy(Option<bool>),
26    Approvals(Option<ApprovalPolicy>),
27    Schema(bool),
28    Sql(String),
29    Export(String),
30    Chart(String),
31    Explain(String),
32    Clear,
33    History,
34    Sessions,
35    Resume(String),
36    /// A contract slash command (`/contracts`, `/contract`, `/remember`,
37    /// `/forget`), already translated to the same `ContractsCommand` the
38    /// headless `saya contracts` parser produces. The adapter slice (2b-4)
39    /// hands it to the shared `run_contracts` dispatcher — no second parsing.
40    Contracts(ContractsCommand),
41    Help(Option<String>),
42    Exit,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct SlashParseError(pub String);
47
48impl fmt::Display for SlashParseError {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        f.write_str(&self.0)
51    }
52}
53impl std::error::Error for SlashParseError {}
54
55pub fn parse_slash_command(input: &str) -> Result<Option<SlashCommand>, SlashParseError> {
56    let trimmed = input.trim();
57    if !trimmed.starts_with('/') {
58        return Ok(None);
59    }
60    let mut parts = trimmed[1..].split_whitespace();
61    let name = parts.next().unwrap_or_default();
62    let arg = parts.collect::<Vec<_>>().join(" ");
63    let required = || {
64        (!arg.is_empty())
65            .then_some(arg.clone())
66            .ok_or_else(|| SlashParseError("command requires an argument".into()))
67    };
68    let command = match name {
69        "connect" => SlashCommand::Connect(required()?),
70        "connections" => SlashCommand::Connections,
71        "include" => SlashCommand::Include(required()?),
72        "exclude" => SlashCommand::Exclude(required()?),
73        "provider" => SlashCommand::Provider((!arg.is_empty()).then_some(arg)),
74        "model" => SlashCommand::Model((!arg.is_empty()).then_some(arg)),
75        "privacy" => SlashCommand::Privacy(parse_bool(&arg)?),
76        "approvals" => SlashCommand::Approvals(parse_approval(&arg)?),
77        "schema" => SlashCommand::Schema(arg == "refresh"),
78        "sql" => {
79            let query = trimmed.strip_prefix("/sql").unwrap_or("").trim();
80            if query.is_empty() {
81                return Err(SlashParseError("sql requires a query".into()));
82            }
83            SlashCommand::Sql(query.to_string())
84        }
85        "export" => {
86            let path = trimmed.strip_prefix("/export").unwrap_or("").trim();
87            if path.is_empty() {
88                return Err(SlashParseError(
89                    "export requires a file path, e.g. /export out.csv".into(),
90                ));
91            }
92            SlashCommand::Export(path.to_string())
93        }
94        "chart" => SlashCommand::Chart(arg.trim().to_string()),
95        "explain" => SlashCommand::Explain(arg.trim().to_string()),
96        "clear" => SlashCommand::Clear,
97        "history" => SlashCommand::History,
98        "sessions" => SlashCommand::Sessions,
99        "resume" => SlashCommand::Resume(required()?),
100        "contracts" | "contract" | "remember" | "forget" | "queue" | "confirm" | "reject" => {
101            // The contract slash adapters: translate to the same
102            // `ContractsCommand` the headless parser produces and hand it to the
103            // shared dispatcher. No second parsing or DTO mapping lives here.
104            // `confirm`/`reject` (spec D) translate to `ContractsCommand::Decide`.
105            return contracts::parse_contract_command(name, &arg)
106                .map(|maybe| maybe.map(SlashCommand::Contracts));
107        }
108        "help" => SlashCommand::Help((!arg.is_empty()).then_some(arg)),
109        "exit" | "quit" => SlashCommand::Exit,
110        other => {
111            let msg = match registry::closest_command(other) {
112                Some(sugg) => format!("unknown command: /{other} (did you mean /{sugg}?)"),
113                None => format!("unknown command: /{other}"),
114            };
115            return Err(SlashParseError(msg));
116        }
117    };
118    Ok(Some(command))
119}
120
121fn parse_bool(value: &str) -> Result<Option<bool>, SlashParseError> {
122    if value.is_empty() {
123        return Ok(None);
124    }
125    match value {
126        "on" | "true" | "enable" => Ok(Some(true)),
127        "off" | "false" | "disable" => Ok(Some(false)),
128        _ => Err(SlashParseError("privacy expects on or off".into())),
129    }
130}
131
132fn parse_approval(value: &str) -> Result<Option<ApprovalPolicy>, SlashParseError> {
133    if value.is_empty() {
134        return Ok(None);
135    }
136    ApprovalPolicy::from_str(value)
137        .map(Some)
138        .map_err(|error| SlashParseError(error.to_string()))
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_help_command() {
147        assert_eq!(
148            parse_slash_command("/help"),
149            Ok(Some(SlashCommand::Help(None)))
150        );
151        assert_eq!(
152            parse_slash_command("/help connect"),
153            Ok(Some(SlashCommand::Help(Some("connect".into()))))
154        );
155
156        let help_connect = help_for(Some("connect"));
157        assert!(help_connect.contains("connect"));
158        assert!(help_connect.contains("Example"));
159
160        let help_unknown = help_for(Some("nope"));
161        assert!(help_unknown.contains("No help"));
162
163        assert_eq!(help_for(None), help_text().to_string());
164    }
165
166    #[test]
167    fn test_parse_sessions_and_resume() {
168        assert_eq!(
169            parse_slash_command("/sessions"),
170            Ok(Some(SlashCommand::Sessions))
171        );
172        assert_eq!(
173            parse_slash_command("/resume 12345"),
174            Ok(Some(SlashCommand::Resume("12345".into())))
175        );
176        assert_eq!(
177            parse_slash_command("/resume"),
178            Err(SlashParseError("command requires an argument".into()))
179        );
180    }
181
182    #[test]
183    fn test_parse_sql_command() {
184        assert_eq!(
185            parse_slash_command("/sql SELECT * FROM users;"),
186            Ok(Some(SlashCommand::Sql("SELECT * FROM users;".into())))
187        );
188        assert_eq!(
189            parse_slash_command("/sql   SELECT  a,  b  FROM  table  "),
190            Ok(Some(SlashCommand::Sql("SELECT  a,  b  FROM  table".into())))
191        );
192        assert_eq!(
193            parse_slash_command("/sql"),
194            Err(SlashParseError("sql requires a query".into()))
195        );
196        assert_eq!(
197            parse_slash_command("/sql   "),
198            Err(SlashParseError("sql requires a query".into()))
199        );
200    }
201
202    #[test]
203    fn test_parse_export_command() {
204        assert_eq!(
205            parse_slash_command("/export out.csv"),
206            Ok(Some(SlashCommand::Export("out.csv".into())))
207        );
208        assert_eq!(
209            parse_slash_command("/export"),
210            Err(SlashParseError(
211                "export requires a file path, e.g. /export out.csv".into()
212            ))
213        );
214    }
215
216    #[test]
217    fn test_parse_chart_command() {
218        assert_eq!(
219            parse_slash_command("/chart"),
220            Ok(Some(SlashCommand::Chart("".into())))
221        );
222        assert_eq!(
223            parse_slash_command("/chart foo"),
224            Ok(Some(SlashCommand::Chart("foo".into())))
225        );
226    }
227
228    #[test]
229    fn test_parse_explain_command() {
230        assert_eq!(
231            parse_slash_command("/explain"),
232            Ok(Some(SlashCommand::Explain("".into())))
233        );
234        assert_eq!(
235            parse_slash_command("/explain SELECT 1"),
236            Ok(Some(SlashCommand::Explain("SELECT 1".into())))
237        );
238    }
239
240    #[test]
241    fn test_unknown_command_suggestion() {
242        let err = parse_slash_command("/conect prod").unwrap_err();
243        assert!(
244            err.0.contains("did you mean /connect"),
245            "expected suggestion in error message, got: {}",
246            err.0
247        );
248
249        let err = parse_slash_command("/zzzzzzzz").unwrap_err();
250        assert!(
251            !err.0.contains("did you mean"),
252            "unexpected suggestion in error message, got: {}",
253            err.0
254        );
255
256        assert_eq!(
257            parse_slash_command("/connect prod"),
258            Ok(Some(SlashCommand::Connect("prod".into())))
259        );
260    }
261}