Skip to main content

zeph_orchestration/
command.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use super::error::OrchestrationError;
5
6/// Typed representation of a parsed `/plan` CLI command.
7///
8/// # Parsing ambiguity
9///
10/// Goals that begin with a reserved word (`status`, `list`, `cancel`, `confirm`, `resume`, `retry`)
11/// will be interpreted as that subcommand, not as a goal. For example:
12/// `/plan status report` parses as `Status(Some("report"))`.
13/// To work around this, rephrase the goal: `/plan write a status report`.
14#[derive(Debug, PartialEq)]
15pub enum PlanCommand {
16    /// `/plan <goal>` — decompose goal, confirm, execute, aggregate.
17    Goal(String),
18    /// `/plan status` or `/plan status <graph-id>` — show DAG progress.
19    Status(Option<String>),
20    /// `/plan list` — list recent graphs from persistence.
21    List,
22    /// `/plan cancel` or `/plan cancel <graph-id>` — cancel active/specific graph.
23    Cancel(Option<String>),
24    /// `/plan confirm` — confirm pending plan before execution.
25    Confirm,
26    /// `/plan resume` or `/plan resume <graph-id>` — resume a paused graph (Ask strategy).
27    Resume(Option<String>),
28    /// `/plan retry` or `/plan retry <graph-id>` — re-run failed tasks in a graph.
29    Retry(Option<String>),
30}
31
32impl PlanCommand {
33    /// Parse from raw input text starting with `/plan`.
34    ///
35    /// # Errors
36    ///
37    /// Returns [`OrchestrationError::InvalidCommand`] if parsing fails.
38    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            // Everything else is treated as a goal (the full `rest` string, not just `cmd`).
97            // This means `/plan refactor the auth module` captures the whole phrase.
98            _ => 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        // "/plan list all modules" could be mistaken for a list-all request,
152        // but we now return an error instead of silently dropping "all modules".
153        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        // Known limitation: "/plan cancel the old endpoints" parses as Cancel, not Goal.
175        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        // "create" is NOT a reserved subcommand — treated as goal.
213        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        // "/plan confirm the test results" should error, not silently Confirm.
253        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}