1#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum ForgePrompt {
11 NewSession,
13 ResumeSession { session_id: String },
16 Handoff,
21 SetMode { mode: String },
23 ShowMode,
26 ShowEffort,
29 SetModel { model: String },
31 SetEffort { level: String },
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum Invocation {
38 Command(ForgePrompt),
40 Misuse(&'static str),
44}
45
46impl ForgePrompt {
47 pub const fn respawns(&self) -> bool {
54 matches!(self, Self::NewSession | Self::ResumeSession { .. } | Self::Handoff)
55 }
56}
57
58const USAGE: &[(&str, &str)] = &[
61 ("/new", "Usage: /new"),
62 ("/resume", "Usage: /resume <session_id>"),
63 ("/handoff", "Usage: /handoff"),
64 ("/mode", "Usage: /mode <id>"),
65 ("/model", "Usage: /model <id>"),
66 ("/effort", "Usage: /effort <level>"),
67];
68
69pub const FORWARDED: [&str; 4] = ["/help", "/mcp", "/plugins", "/quit"];
82
83pub fn forge_prompt_names() -> impl Iterator<Item = &'static str> {
86 USAGE.iter().map(|(name, _)| *name)
87}
88
89pub fn is_forge_prompt_name(name: &str) -> bool {
91 usage_for(name).is_some()
92}
93
94pub fn is_forwarded_name(name: &str) -> bool {
96 FORWARDED.contains(&name)
97}
98
99pub fn forge_invocation(text: &str) -> Option<Invocation> {
105 let mut words = text.split_whitespace();
106 let name = words.next()?;
107 let arguments: Vec<&str> = words.collect();
108 let command = match (name, arguments.as_slice()) {
109 ("/new", []) => Some(ForgePrompt::NewSession),
110 ("/resume", [arg]) => Some(ForgePrompt::ResumeSession { session_id: (*arg).to_owned() }),
111 ("/handoff", []) => Some(ForgePrompt::Handoff),
112 ("/mode", []) => Some(ForgePrompt::ShowMode),
113 ("/mode", [arg]) => Some(ForgePrompt::SetMode { mode: (*arg).to_owned() }),
114 ("/model", [arg]) => Some(ForgePrompt::SetModel { model: (*arg).to_owned() }),
115 ("/effort", []) => Some(ForgePrompt::ShowEffort),
116 ("/effort", [arg]) => Some(ForgePrompt::SetEffort { level: (*arg).to_owned() }),
117 _ => None,
118 };
119 match command {
120 Some(command) => Some(Invocation::Command(command)),
121 None => usage_for(name).map(Invocation::Misuse),
122 }
123}
124
125fn usage_for(name: &str) -> Option<&'static str> {
126 USAGE.iter().find(|(known, _)| *known == name).map(|(_, usage)| *usage)
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn a_lone_command_name_is_forges() {
135 assert_eq!(forge_invocation("/new"), Some(Invocation::Command(ForgePrompt::NewSession)));
136 assert_eq!(
137 forge_invocation(" /new "),
138 Some(Invocation::Command(ForgePrompt::NewSession)),
139 );
140 }
141
142 #[test]
143 fn a_command_with_its_argument_carries_it() {
144 let named = |session_id: &str| {
145 Some(Invocation::Command(ForgePrompt::ResumeSession {
146 session_id: session_id.to_owned(),
147 }))
148 };
149 assert_eq!(forge_invocation("/resume 7f3a92e0"), named("7f3a92e0"));
150 assert_eq!(
151 forge_invocation(" /resume 7f3a92e0 "),
152 named("7f3a92e0"),
153 "the argument is read the way a shell would read it",
154 );
155 }
156
157 #[test]
160 fn handoff_is_a_respawn_with_no_argument() {
161 assert_eq!(forge_invocation("/handoff"), Some(Invocation::Command(ForgePrompt::Handoff)));
162 assert!(ForgePrompt::Handoff.respawns(), "the swap replaces the occupant");
163 assert_eq!(
164 forge_invocation("/handoff now"),
165 Some(Invocation::Misuse("Usage: /handoff")),
166 "a brief is not an argument the command takes",
167 );
168 }
169
170 #[test]
173 fn a_wrong_invocation_is_answered_with_its_usage() {
174 assert_eq!(forge_invocation("/new session"), Some(Invocation::Misuse("Usage: /new")),);
175 assert_eq!(
176 forge_invocation("/resume"),
177 Some(Invocation::Misuse("Usage: /resume <session_id>")),
178 );
179 assert_eq!(
180 forge_invocation("/resume two ids"),
181 Some(Invocation::Misuse("Usage: /resume <session_id>")),
182 );
183 assert_eq!(
184 forge_invocation("/new\nsecond line"),
185 Some(Invocation::Misuse("Usage: /new")),
186 "a command with words after it is answered, not read as prose",
187 );
188 }
189
190 #[test]
194 fn a_bare_mode_or_effort_asks_for_the_value_in_force() {
195 assert_eq!(forge_invocation("/mode"), Some(Invocation::Command(ForgePrompt::ShowMode)));
196 assert_eq!(
197 forge_invocation(" /effort "),
198 Some(Invocation::Command(ForgePrompt::ShowEffort)),
199 );
200 assert_eq!(
203 forge_invocation("/mode plan"),
204 Some(Invocation::Command(ForgePrompt::SetMode { mode: "plan".to_owned() })),
205 );
206 assert_eq!(
207 forge_invocation("/effort high low"),
208 Some(Invocation::Misuse("Usage: /effort <level>")),
209 );
210 }
211
212 #[test]
214 fn anything_but_a_whole_command_word_is_prose() {
215 for text in ["/newer", "hello /new", "/NEW", "/spinner", "hello", "/ne"] {
216 assert_eq!(forge_invocation(text), None, "{text:?} is not a forge command");
217 }
218 }
219
220 #[test]
229 fn every_name_the_table_offers_can_be_run() {
230 let examples = [
231 ("/new", "/new"),
232 ("/resume", "/resume 7f3a92e0"),
233 ("/handoff", "/handoff"),
234 ("/mode", "/mode plan"),
235 ("/model", "/model sonnet"),
236 ("/effort", "/effort high"),
237 ];
238 for name in forge_prompt_names() {
239 let Some((_, example)) = examples.iter().find(|(known, _)| *known == name) else {
240 panic!("{name} is offered with no example here, so nothing pins that it runs");
241 };
242 assert!(
243 matches!(forge_invocation(example), Some(Invocation::Command(_))),
244 "{name} is offered and cannot be run",
245 );
246 assert!(is_forge_prompt_name(name), "{name} is a name a view forwards on");
247 }
248 }
249
250 #[test]
255 fn the_clients_answer_sets_match_this_files() {
256 const CLIENT: &str =
257 concat!(env!("CARGO_MANIFEST_DIR"), "/../../client/src/composer/forge-commands.ts");
258 let source = std::fs::read_to_string(CLIENT)
259 .unwrap_or_else(|error| panic!("the client's forge commands are readable: {error}"));
260
261 let set = |marker: &str| -> Vec<String> {
262 let start = source
263 .find(marker)
264 .unwrap_or_else(|| panic!("the client still declares {marker:?}"));
265 let block = &source[start + marker.len()..];
266 let end = block
267 .find("]);")
268 .unwrap_or_else(|| panic!("the client's set closes after its entries"));
269 let mut names: Vec<String> = block[..end]
270 .split(',')
271 .map(|entry| entry.trim().trim_matches('\'').trim_matches('"').to_owned())
272 .filter(|name| !name.is_empty())
273 .collect();
274 names.sort();
275 names
276 };
277
278 let mut answered: Vec<String> = forge_prompt_names().map(str::to_owned).collect();
279 answered.sort();
280 assert_eq!(
281 set("const ANSWERED = new Set(["),
282 answered,
283 "the client answers exactly the names this file does",
284 );
285 let mut forwarded: Vec<String> = FORWARDED.iter().map(|name| (*name).to_owned()).collect();
286 forwarded.sort();
287 assert_eq!(
288 set("const FORWARDED = new Set(["),
289 forwarded,
290 "and forwards exactly the names this file forwards",
291 );
292 }
293}