starweaver_cli/
slash_commands.rs1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
9pub struct SlashCommandDefinition {
10 pub name: String,
12 pub prompt: String,
14 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub description: Option<String>,
17 #[serde(default, skip_serializing_if = "Vec::is_empty")]
19 pub aliases: Vec<String>,
20}
21
22#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct ExpandedSlashCommand {
25 pub invoked_name: String,
27 pub command_name: String,
29 pub prompt: String,
31 pub args: String,
33 pub description: Option<String>,
35}
36
37pub fn expand_slash_command(
39 commands: &BTreeMap<String, SlashCommandDefinition>,
40 input: &str,
41) -> Option<ExpandedSlashCommand> {
42 let trimmed = input.trim();
43 let rest = trimmed.strip_prefix('/')?.trim_start();
44 if rest.is_empty() {
45 return None;
46 }
47 let name_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
48 let invoked_name = normalize_command_name(&rest[..name_end]);
49 if invoked_name.is_empty() {
50 return None;
51 }
52 let args = rest[name_end..].trim().to_string();
53 let definition = commands.get(&invoked_name)?;
54 Some(ExpandedSlashCommand {
55 invoked_name,
56 command_name: normalize_command_name(&definition.name),
57 prompt: prompt_with_args(&definition.prompt, &args),
58 args,
59 description: definition.description.clone(),
60 })
61}
62
63#[must_use]
65pub fn normalize_command_name(name: &str) -> String {
66 name.trim().trim_start_matches('/').to_ascii_lowercase()
67}
68
69#[must_use]
71pub fn valid_command_name(name: &str) -> bool {
72 !name.is_empty()
73 && name
74 .chars()
75 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
76}
77
78const ARG_PLACEHOLDERS: [(&str, usize); 4] = [
79 ("{{instruction}}", "{{instruction}}".len()),
80 ("{{args}}", "{{args}}".len()),
81 ("{instruction}", "{instruction}".len()),
82 ("{args}", "{args}".len()),
83];
84
85fn next_args_placeholder(value: &str) -> Option<(usize, usize)> {
86 ARG_PLACEHOLDERS
87 .iter()
88 .filter_map(|(placeholder, len)| value.find(placeholder).map(|index| (index, *len)))
89 .min_by_key(|(index, _)| *index)
90}
91
92fn prompt_with_args(prompt: &str, args: &str) -> String {
93 if next_args_placeholder(prompt).is_some() {
94 let mut output = String::new();
95 let mut rest = prompt;
96 while let Some((index, placeholder_len)) = next_args_placeholder(rest) {
97 output.push_str(&rest[..index]);
98 output.push_str(args);
99 rest = &rest[index + placeholder_len..];
100 }
101 output.push_str(rest);
102 return output;
103 }
104 if args.is_empty() {
105 return prompt.to_string();
106 }
107 let mut expanded = prompt.trim_end().to_string();
108 if !expanded.is_empty() {
109 expanded.push_str("\n\n");
110 }
111 expanded.push_str("User instruction: ");
112 expanded.push_str(args);
113 expanded
114}
115
116#[cfg(test)]
117mod tests {
118 #![allow(clippy::unwrap_used)]
119
120 use super::*;
121
122 fn command(prompt: &str) -> SlashCommandDefinition {
123 SlashCommandDefinition {
124 name: "review".to_string(),
125 prompt: prompt.to_string(),
126 description: Some("Review changes".to_string()),
127 aliases: vec!["rv".to_string()],
128 }
129 }
130
131 #[test]
132 fn expands_command_alias_and_arguments() {
133 let mut commands = BTreeMap::new();
134 let definition = command("Review the changes.");
135 commands.insert("review".to_string(), definition.clone());
136 commands.insert("rv".to_string(), definition);
137
138 let expanded = expand_slash_command(&commands, " /RV src/lib.rs ").unwrap();
139 assert_eq!(expanded.invoked_name, "rv");
140 assert_eq!(expanded.command_name, "review");
141 assert_eq!(
142 expanded.prompt,
143 "Review the changes.\n\nUser instruction: src/lib.rs"
144 );
145 }
146
147 #[test]
148 fn replaces_args_placeholders_when_present() {
149 let mut commands = BTreeMap::new();
150 commands.insert("test".to_string(), command("Run tests for {{args}}."));
151
152 let expanded = expand_slash_command(&commands, "/test crates/starweaver-cli").unwrap();
153 assert_eq!(expanded.prompt, "Run tests for crates/starweaver-cli.");
154 }
155
156 #[test]
157 fn replaces_instruction_placeholders_when_present() {
158 let mut commands = BTreeMap::new();
159 commands.insert(
160 "commit".to_string(),
161 command("Create a commit using {instruction}."),
162 );
163
164 let expanded = expand_slash_command(&commands, "/commit staged fixes").unwrap();
165 assert_eq!(expanded.prompt, "Create a commit using staged fixes.");
166 }
167
168 #[test]
169 fn ignores_non_commands_and_unknown_commands() {
170 let commands = BTreeMap::new();
171 assert!(expand_slash_command(&commands, "hello").is_none());
172 assert!(expand_slash_command(&commands, "/missing args").is_none());
173 }
174}