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