use super::error::OrchestrationError;
#[derive(Debug, PartialEq)]
pub enum PlanCommand {
Goal(String),
Status(Option<String>),
List,
Cancel(Option<String>),
Confirm,
Resume(Option<String>),
Retry(Option<String>),
}
impl PlanCommand {
pub fn parse(input: &str) -> Result<Self, OrchestrationError> {
let rest = input
.strip_prefix("/plan")
.ok_or_else(|| {
OrchestrationError::InvalidCommand("input must start with /plan".into())
})?
.trim();
if rest.is_empty() {
return Err(OrchestrationError::InvalidCommand(
"usage: /plan <goal> | /plan status [id] | /plan list | /plan cancel [id] \
| /plan confirm | /plan resume [id] | /plan retry [id]\n\
Note: goals starting with a reserved word are parsed as subcommands."
.into(),
));
}
let (cmd, args) = rest.split_once(' ').unwrap_or((rest, ""));
let cmd = cmd.trim();
let args = args.trim();
match cmd {
"status" => Ok(Self::Status(if args.is_empty() {
None
} else {
Some(args.to_owned())
})),
"list" => {
if !args.is_empty() {
return Err(OrchestrationError::InvalidCommand(
"/plan list takes no arguments".into(),
));
}
Ok(Self::List)
}
"cancel" => Ok(Self::Cancel(if args.is_empty() {
None
} else {
Some(args.to_owned())
})),
"confirm" => {
if !args.is_empty() {
return Err(OrchestrationError::InvalidCommand(
"/plan confirm takes no arguments".into(),
));
}
Ok(Self::Confirm)
}
"resume" => Ok(Self::Resume(if args.is_empty() {
None
} else {
Some(args.to_owned())
})),
"retry" => Ok(Self::Retry(if args.is_empty() {
None
} else {
Some(args.to_owned())
})),
_ => Ok(Self::Goal(rest.to_owned())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_goal_simple() {
let cmd = PlanCommand::parse("/plan refactor auth module").unwrap();
assert_eq!(cmd, PlanCommand::Goal("refactor auth module".into()));
}
#[test]
fn parse_goal_multi_word() {
let cmd = PlanCommand::parse("/plan build a new feature for the dashboard").unwrap();
assert_eq!(
cmd,
PlanCommand::Goal("build a new feature for the dashboard".into())
);
}
#[test]
fn parse_status_no_id() {
let cmd = PlanCommand::parse("/plan status").unwrap();
assert_eq!(cmd, PlanCommand::Status(None));
}
#[test]
fn parse_status_with_id() {
let cmd = PlanCommand::parse("/plan status abc-123").unwrap();
assert_eq!(cmd, PlanCommand::Status(Some("abc-123".into())));
}
#[test]
fn parse_list() {
let cmd = PlanCommand::parse("/plan list").unwrap();
assert_eq!(cmd, PlanCommand::List);
}
#[test]
fn parse_list_with_args_returns_error() {
let err = PlanCommand::parse("/plan list all modules").unwrap_err();
assert!(
matches!(err, OrchestrationError::InvalidCommand(ref m) if m.contains("no arguments")),
"expected no-arguments error, got: {err:?}"
);
}
#[test]
fn parse_list_trailing_args_documents_known_ambiguity() {
let result = PlanCommand::parse("/plan list all modules");
assert!(
result.is_err(),
"should error, not silently drop trailing args"
);
}
#[test]
fn parse_cancel_no_id() {
let cmd = PlanCommand::parse("/plan cancel").unwrap();
assert_eq!(cmd, PlanCommand::Cancel(None));
}
#[test]
fn parse_cancel_with_id() {
let cmd = PlanCommand::parse("/plan cancel abc-123").unwrap();
assert_eq!(cmd, PlanCommand::Cancel(Some("abc-123".into())));
}
#[test]
fn parse_cancel_with_phrase_ambiguity() {
let cmd = PlanCommand::parse("/plan cancel the old endpoints").unwrap();
assert_eq!(
cmd,
PlanCommand::Cancel(Some("the old endpoints".into())),
"cancel captures the rest as optional id — known ambiguity with natural language goals"
);
}
#[test]
fn parse_confirm() {
let cmd = PlanCommand::parse("/plan confirm").unwrap();
assert_eq!(cmd, PlanCommand::Confirm);
}
#[test]
fn parse_empty_after_prefix_returns_error() {
let err = PlanCommand::parse("/plan").unwrap_err();
assert!(
matches!(err, OrchestrationError::InvalidCommand(ref m) if m.contains("usage")),
"expected usage error, got: {err:?}"
);
}
#[test]
fn parse_whitespace_only_after_prefix_returns_error() {
let err = PlanCommand::parse("/plan ").unwrap_err();
assert!(matches!(err, OrchestrationError::InvalidCommand(ref m) if m.contains("usage")));
}
#[test]
fn parse_wrong_prefix_returns_error() {
let err = PlanCommand::parse("/foo bar").unwrap_err();
assert!(matches!(err, OrchestrationError::InvalidCommand(ref m) if m.contains("/plan")));
}
#[test]
fn parse_goal_with_unreserved_word_at_start() {
let cmd = PlanCommand::parse("/plan create a status report").unwrap();
assert_eq!(cmd, PlanCommand::Goal("create a status report".into()));
}
#[test]
fn parse_resume_no_id() {
let cmd = PlanCommand::parse("/plan resume").unwrap();
assert_eq!(cmd, PlanCommand::Resume(None));
}
#[test]
fn parse_resume_with_id() {
let cmd = PlanCommand::parse("/plan resume abc-123").unwrap();
assert_eq!(cmd, PlanCommand::Resume(Some("abc-123".into())));
}
#[test]
fn parse_retry_no_id() {
let cmd = PlanCommand::parse("/plan retry").unwrap();
assert_eq!(cmd, PlanCommand::Retry(None));
}
#[test]
fn parse_retry_with_id() {
let cmd = PlanCommand::parse("/plan retry abc-123").unwrap();
assert_eq!(cmd, PlanCommand::Retry(Some("abc-123".into())));
}
#[test]
fn parse_confirm_with_trailing_args_returns_error() {
let err = PlanCommand::parse("/plan confirm abc-123").unwrap_err();
assert!(
matches!(err, OrchestrationError::InvalidCommand(ref m) if m.contains("no arguments")),
"expected no-arguments error, got: {err:?}"
);
}
#[test]
fn parse_confirm_with_phrase_returns_error() {
let err = PlanCommand::parse("/plan confirm the test results").unwrap_err();
assert!(
matches!(err, OrchestrationError::InvalidCommand(ref m) if m.contains("no arguments")),
"expected no-arguments error, got: {err:?}"
);
}
}