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