tl_cli/chat/
command.rs

1use inquire::autocompletion::{Autocomplete, Replacement};
2
3// Available slash commands: (command, description)
4const SLASH_COMMANDS: &[(&str, &str)] = &[
5    ("/config", "Show current configuration"),
6    ("/help", "Show available commands"),
7    ("/quit", "Exit chat mode"),
8    ("/set", "Set option (style, to, model)"),
9];
10
11/// Slash command autocompleter
12#[derive(Clone, Default)]
13pub struct SlashCommandCompleter;
14
15impl Autocomplete for SlashCommandCompleter {
16    fn get_suggestions(&mut self, input: &str) -> Result<Vec<String>, inquire::CustomUserError> {
17        if !input.starts_with('/') {
18            return Ok(vec![]);
19        }
20
21        let suggestions: Vec<String> = SLASH_COMMANDS
22            .iter()
23            .filter(|(cmd, _)| cmd.starts_with(input))
24            .map(|(cmd, desc)| format!("{cmd}  {desc}"))
25            .collect();
26
27        Ok(suggestions)
28    }
29
30    fn get_completion(
31        &mut self,
32        _input: &str,
33        highlighted_suggestion: Option<String>,
34    ) -> Result<Replacement, inquire::CustomUserError> {
35        let replacement =
36            highlighted_suggestion.map(|s| s.split_whitespace().next().unwrap_or("").to_string());
37        Ok(replacement)
38    }
39}
40
41/// Slash command types
42#[derive(Debug, Clone)]
43pub enum SlashCommand {
44    Config,
45    Help,
46    Quit,
47    Set { key: String, value: Option<String> },
48    Unknown(String),
49}
50
51/// Input types
52#[derive(Debug)]
53pub enum Input {
54    Text(String),
55    Command(SlashCommand),
56    Empty,
57}
58
59pub fn parse_input(input: &str) -> Input {
60    let input = input.trim();
61
62    if input.is_empty() {
63        return Input::Empty;
64    }
65
66    input
67        .strip_prefix('/')
68        .map_or_else(|| Input::Text(input.to_string()), parse_slash_command)
69}
70
71fn parse_slash_command(cmd: &str) -> Input {
72    let parts: Vec<&str> = cmd.split_whitespace().collect();
73
74    match parts.first().copied() {
75        Some("config") => Input::Command(SlashCommand::Config),
76        Some("help") => Input::Command(SlashCommand::Help),
77        Some("quit" | "exit" | "q") => Input::Command(SlashCommand::Quit),
78        Some("set") => {
79            let key = parts.get(1).map(|s| (*s).to_string()).unwrap_or_default();
80            let value = parts.get(2).map(|s| (*s).to_string());
81            Input::Command(SlashCommand::Set { key, value })
82        }
83        _ => Input::Command(SlashCommand::Unknown(parts.join(" "))),
84    }
85}
86
87#[cfg(test)]
88#[allow(clippy::unwrap_used)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn test_parse_empty_input() {
94        assert!(matches!(parse_input(""), Input::Empty));
95        assert!(matches!(parse_input("   "), Input::Empty));
96    }
97
98    #[test]
99    fn test_parse_text_input() {
100        match parse_input("Hello, world!") {
101            Input::Text(text) => assert_eq!(text, "Hello, world!"),
102            _ => panic!("Expected Input::Text"),
103        }
104    }
105
106    #[test]
107    fn test_parse_config_command() {
108        assert!(matches!(
109            parse_input("/config"),
110            Input::Command(SlashCommand::Config)
111        ));
112    }
113
114    #[test]
115    fn test_parse_help_command() {
116        assert!(matches!(
117            parse_input("/help"),
118            Input::Command(SlashCommand::Help)
119        ));
120    }
121
122    #[test]
123    fn test_parse_quit_commands() {
124        assert!(matches!(
125            parse_input("/quit"),
126            Input::Command(SlashCommand::Quit)
127        ));
128        assert!(matches!(
129            parse_input("/exit"),
130            Input::Command(SlashCommand::Quit)
131        ));
132        assert!(matches!(
133            parse_input("/q"),
134            Input::Command(SlashCommand::Quit)
135        ));
136    }
137
138    #[test]
139    fn test_parse_unknown_command() {
140        match parse_input("/unknown") {
141            Input::Command(SlashCommand::Unknown(cmd)) => assert_eq!(cmd, "unknown"),
142            _ => panic!("Expected Input::Command(SlashCommand::Unknown)"),
143        }
144    }
145
146    // /set command tests
147
148    #[test]
149    fn test_parse_set_style_with_value() {
150        match parse_input("/set style casual") {
151            Input::Command(SlashCommand::Set { key, value }) => {
152                assert_eq!(key, "style");
153                assert_eq!(value, Some("casual".to_string()));
154            }
155            _ => panic!("Expected Input::Command(SlashCommand::Set)"),
156        }
157    }
158
159    #[test]
160    fn test_parse_set_style_without_value() {
161        match parse_input("/set style") {
162            Input::Command(SlashCommand::Set { key, value }) => {
163                assert_eq!(key, "style");
164                assert_eq!(value, None);
165            }
166            _ => panic!("Expected Input::Command(SlashCommand::Set)"),
167        }
168    }
169
170    #[test]
171    fn test_parse_set_to() {
172        match parse_input("/set to ja") {
173            Input::Command(SlashCommand::Set { key, value }) => {
174                assert_eq!(key, "to");
175                assert_eq!(value, Some("ja".to_string()));
176            }
177            _ => panic!("Expected Input::Command(SlashCommand::Set)"),
178        }
179    }
180
181    #[test]
182    fn test_parse_set_model() {
183        match parse_input("/set model gpt-4o") {
184            Input::Command(SlashCommand::Set { key, value }) => {
185                assert_eq!(key, "model");
186                assert_eq!(value, Some("gpt-4o".to_string()));
187            }
188            _ => panic!("Expected Input::Command(SlashCommand::Set)"),
189        }
190    }
191
192    #[test]
193    fn test_parse_set_without_key() {
194        match parse_input("/set") {
195            Input::Command(SlashCommand::Set { key, value }) => {
196                assert_eq!(key, "");
197                assert_eq!(value, None);
198            }
199            _ => panic!("Expected Input::Command(SlashCommand::Set)"),
200        }
201    }
202
203    #[test]
204    fn test_parse_set_with_extra_whitespace() {
205        match parse_input("/set   style   casual") {
206            Input::Command(SlashCommand::Set { key, value }) => {
207                assert_eq!(key, "style");
208                assert_eq!(value, Some("casual".to_string()));
209            }
210            _ => panic!("Expected Input::Command(SlashCommand::Set)"),
211        }
212    }
213
214    // SlashCommandCompleter tests
215
216    #[test]
217    fn test_completer_no_suggestions_for_regular_text() {
218        let mut completer = SlashCommandCompleter;
219        let suggestions = completer.get_suggestions("hello").unwrap();
220        assert!(suggestions.is_empty());
221    }
222
223    #[test]
224    fn test_completer_suggestions_for_slash() {
225        let mut completer = SlashCommandCompleter;
226        let suggestions = completer.get_suggestions("/").unwrap();
227        assert_eq!(suggestions.len(), 4); // /config, /help, /quit, /set
228    }
229
230    #[test]
231    fn test_completer_suggestions_filter_by_prefix() {
232        let mut completer = SlashCommandCompleter;
233
234        let suggestions = completer.get_suggestions("/c").unwrap();
235        assert_eq!(suggestions.len(), 1);
236        assert!(suggestions[0].starts_with("/config"));
237
238        let suggestions = completer.get_suggestions("/q").unwrap();
239        assert_eq!(suggestions.len(), 1);
240        assert!(suggestions[0].starts_with("/quit"));
241    }
242
243    #[test]
244    fn test_completer_completion() {
245        let mut completer = SlashCommandCompleter;
246        let suggestion = "/config  Show current configuration".to_string();
247        let completion = completer.get_completion("/c", Some(suggestion)).unwrap();
248        assert_eq!(completion, Some("/config".to_string()));
249    }
250
251    #[test]
252    fn test_completer_completion_none() {
253        let mut completer = SlashCommandCompleter;
254        let completion = completer.get_completion("/x", None).unwrap();
255        assert!(completion.is_none());
256    }
257}