zeph_orchestration/
command.rs1use super::error::OrchestrationError;
5
6#[derive(Debug, PartialEq)]
15pub enum PlanCommand {
16 Goal(String),
18 Status(Option<String>),
20 List,
22 Cancel(Option<String>),
24 Confirm,
26 Resume(Option<String>),
28 Retry(Option<String>),
30}
31
32impl PlanCommand {
33 pub fn parse(input: &str) -> Result<Self, OrchestrationError> {
39 let rest = input
40 .strip_prefix("/plan")
41 .ok_or_else(|| {
42 OrchestrationError::InvalidCommand("input must start with /plan".into())
43 })?
44 .trim();
45
46 if rest.is_empty() {
47 return Err(OrchestrationError::InvalidCommand(
48 "usage: /plan <goal> | /plan status [id] | /plan list | /plan cancel [id] \
49 | /plan confirm | /plan resume [id] | /plan retry [id]\n\
50 Note: goals starting with a reserved word are parsed as subcommands."
51 .into(),
52 ));
53 }
54
55 let (cmd, args) = rest.split_once(' ').unwrap_or((rest, ""));
56 let cmd = cmd.trim();
57 let args = args.trim();
58
59 match cmd {
60 "status" => Ok(Self::Status(if args.is_empty() {
61 None
62 } else {
63 Some(args.to_owned())
64 })),
65 "list" => {
66 if !args.is_empty() {
67 return Err(OrchestrationError::InvalidCommand(
68 "/plan list takes no arguments".into(),
69 ));
70 }
71 Ok(Self::List)
72 }
73 "cancel" => Ok(Self::Cancel(if args.is_empty() {
74 None
75 } else {
76 Some(args.to_owned())
77 })),
78 "confirm" => {
79 if !args.is_empty() {
80 return Err(OrchestrationError::InvalidCommand(
81 "/plan confirm takes no arguments".into(),
82 ));
83 }
84 Ok(Self::Confirm)
85 }
86 "resume" => Ok(Self::Resume(if args.is_empty() {
87 None
88 } else {
89 Some(args.to_owned())
90 })),
91 "retry" => Ok(Self::Retry(if args.is_empty() {
92 None
93 } else {
94 Some(args.to_owned())
95 })),
96 _ => Ok(Self::Goal(rest.to_owned())),
99 }
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 #[test]
108 fn parse_goal_simple() {
109 let cmd = PlanCommand::parse("/plan refactor auth module").unwrap();
110 assert_eq!(cmd, PlanCommand::Goal("refactor auth module".into()));
111 }
112
113 #[test]
114 fn parse_goal_multi_word() {
115 let cmd = PlanCommand::parse("/plan build a new feature for the dashboard").unwrap();
116 assert_eq!(
117 cmd,
118 PlanCommand::Goal("build a new feature for the dashboard".into())
119 );
120 }
121
122 #[test]
123 fn parse_status_no_id() {
124 let cmd = PlanCommand::parse("/plan status").unwrap();
125 assert_eq!(cmd, PlanCommand::Status(None));
126 }
127
128 #[test]
129 fn parse_status_with_id() {
130 let cmd = PlanCommand::parse("/plan status abc-123").unwrap();
131 assert_eq!(cmd, PlanCommand::Status(Some("abc-123".into())));
132 }
133
134 #[test]
135 fn parse_list() {
136 let cmd = PlanCommand::parse("/plan list").unwrap();
137 assert_eq!(cmd, PlanCommand::List);
138 }
139
140 #[test]
141 fn parse_list_with_args_returns_error() {
142 let err = PlanCommand::parse("/plan list all modules").unwrap_err();
143 assert!(
144 matches!(err, OrchestrationError::InvalidCommand(ref m) if m.contains("no arguments")),
145 "expected no-arguments error, got: {err:?}"
146 );
147 }
148
149 #[test]
150 fn parse_list_trailing_args_documents_known_ambiguity() {
151 let result = PlanCommand::parse("/plan list all modules");
154 assert!(
155 result.is_err(),
156 "should error, not silently drop trailing args"
157 );
158 }
159
160 #[test]
161 fn parse_cancel_no_id() {
162 let cmd = PlanCommand::parse("/plan cancel").unwrap();
163 assert_eq!(cmd, PlanCommand::Cancel(None));
164 }
165
166 #[test]
167 fn parse_cancel_with_id() {
168 let cmd = PlanCommand::parse("/plan cancel abc-123").unwrap();
169 assert_eq!(cmd, PlanCommand::Cancel(Some("abc-123".into())));
170 }
171
172 #[test]
173 fn parse_cancel_with_phrase_ambiguity() {
174 let cmd = PlanCommand::parse("/plan cancel the old endpoints").unwrap();
176 assert_eq!(
177 cmd,
178 PlanCommand::Cancel(Some("the old endpoints".into())),
179 "cancel captures the rest as optional id — known ambiguity with natural language goals"
180 );
181 }
182
183 #[test]
184 fn parse_confirm() {
185 let cmd = PlanCommand::parse("/plan confirm").unwrap();
186 assert_eq!(cmd, PlanCommand::Confirm);
187 }
188
189 #[test]
190 fn parse_empty_after_prefix_returns_error() {
191 let err = PlanCommand::parse("/plan").unwrap_err();
192 assert!(
193 matches!(err, OrchestrationError::InvalidCommand(ref m) if m.contains("usage")),
194 "expected usage error, got: {err:?}"
195 );
196 }
197
198 #[test]
199 fn parse_whitespace_only_after_prefix_returns_error() {
200 let err = PlanCommand::parse("/plan ").unwrap_err();
201 assert!(matches!(err, OrchestrationError::InvalidCommand(ref m) if m.contains("usage")));
202 }
203
204 #[test]
205 fn parse_wrong_prefix_returns_error() {
206 let err = PlanCommand::parse("/foo bar").unwrap_err();
207 assert!(matches!(err, OrchestrationError::InvalidCommand(ref m) if m.contains("/plan")));
208 }
209
210 #[test]
211 fn parse_goal_with_unreserved_word_at_start() {
212 let cmd = PlanCommand::parse("/plan create a status report").unwrap();
214 assert_eq!(cmd, PlanCommand::Goal("create a status report".into()));
215 }
216
217 #[test]
218 fn parse_resume_no_id() {
219 let cmd = PlanCommand::parse("/plan resume").unwrap();
220 assert_eq!(cmd, PlanCommand::Resume(None));
221 }
222
223 #[test]
224 fn parse_resume_with_id() {
225 let cmd = PlanCommand::parse("/plan resume abc-123").unwrap();
226 assert_eq!(cmd, PlanCommand::Resume(Some("abc-123".into())));
227 }
228
229 #[test]
230 fn parse_retry_no_id() {
231 let cmd = PlanCommand::parse("/plan retry").unwrap();
232 assert_eq!(cmd, PlanCommand::Retry(None));
233 }
234
235 #[test]
236 fn parse_retry_with_id() {
237 let cmd = PlanCommand::parse("/plan retry abc-123").unwrap();
238 assert_eq!(cmd, PlanCommand::Retry(Some("abc-123".into())));
239 }
240
241 #[test]
242 fn parse_confirm_with_trailing_args_returns_error() {
243 let err = PlanCommand::parse("/plan confirm abc-123").unwrap_err();
244 assert!(
245 matches!(err, OrchestrationError::InvalidCommand(ref m) if m.contains("no arguments")),
246 "expected no-arguments error, got: {err:?}"
247 );
248 }
249
250 #[test]
251 fn parse_confirm_with_phrase_returns_error() {
252 let err = PlanCommand::parse("/plan confirm the test results").unwrap_err();
254 assert!(
255 matches!(err, OrchestrationError::InvalidCommand(ref m) if m.contains("no arguments")),
256 "expected no-arguments error, got: {err:?}"
257 );
258 }
259}