Skip to main content

starweaver_cli/
slash_commands.rs

1//! Config-backed slash command expansion.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7/// Config-defined slash command prompt.
8#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
9pub struct SlashCommandDefinition {
10    /// Canonical command name without a leading slash.
11    pub name: String,
12    /// Prompt submitted when the command is invoked.
13    pub prompt: String,
14    /// Human-readable description.
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub description: Option<String>,
17    /// Additional aliases without a leading slash.
18    #[serde(default, skip_serializing_if = "Vec::is_empty")]
19    pub aliases: Vec<String>,
20}
21
22/// Expanded prompt produced by invoking a config-backed slash command.
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct ExpandedSlashCommand {
25    /// Alias or command name typed by the user, normalized without `/`.
26    pub invoked_name: String,
27    /// Canonical command name, normalized without `/`.
28    pub command_name: String,
29    /// Prompt submitted to the agent.
30    pub prompt: String,
31    /// Free-form instruction text after the slash command name.
32    pub args: String,
33    /// Human-readable description.
34    pub description: Option<String>,
35}
36
37/// Expand `input` when it invokes a configured slash command.
38pub 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/// Normalize a slash command name for map storage and lookup.
64#[must_use]
65pub fn normalize_command_name(name: &str) -> String {
66    name.trim().trim_start_matches('/').to_ascii_lowercase()
67}
68
69/// Return whether a normalized slash command name can be invoked as one token.
70#[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}