1use crate::cli::ContractsCommand;
2use saya_agent::ApprovalPolicy;
3use std::{fmt, str::FromStr};
4
5mod contracts;
6mod help;
7mod registry;
8
9pub(crate) use help::help_for;
12#[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 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 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}