use serde::{Deserialize, Serialize};
use super::error::OrchestrationError;
use super::graph::TaskId;
#[non_exhaustive]
#[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())),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TaskRef {
ById(TaskId),
ByTitle(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HandoffCommand {
pub goto: TaskRef,
pub update: Vec<(String, String)>,
}
#[derive(Debug, Deserialize)]
struct RawHandoffCommand {
goto: String,
#[serde(default)]
update: serde_json::Map<String, serde_json::Value>,
}
const FENCE_OPEN: &str = "```zeph-command";
const FENCE_CLOSE: &str = "```";
fn parse_task_ref(raw: &str) -> TaskRef {
match raw.parse::<u32>() {
Ok(n) => TaskRef::ById(TaskId(n)),
Err(_) => TaskRef::ByTitle(raw.to_owned()),
}
}
fn extract_fenced_body(trimmed: &str) -> Option<&str> {
let before_close = trimmed.strip_suffix(FENCE_CLOSE)?;
let open_idx = before_close.rfind(FENCE_OPEN)?;
if open_idx != 0 && before_close.as_bytes()[open_idx - 1] != b'\n' {
return None;
}
let after_marker = &before_close[open_idx + FENCE_OPEN.len()..];
let starts_with_boundary = after_marker.chars().next().is_none_or(char::is_whitespace);
if !starts_with_boundary {
return None;
}
let body = after_marker.trim();
if body.is_empty() { None } else { Some(body) }
}
#[must_use]
pub fn has_handoff_fence(text: &str) -> bool {
let trimmed = text.trim_end();
if let Some(before_close) = trimmed.strip_suffix(FENCE_CLOSE)
&& let Some(open_idx) = before_close.rfind(FENCE_OPEN)
{
let boundary_before = open_idx == 0 || before_close.as_bytes()[open_idx - 1] == b'\n';
let after_marker = &before_close[open_idx + FENCE_OPEN.len()..];
let boundary_after = after_marker.chars().next().is_none_or(char::is_whitespace);
if boundary_before && boundary_after {
return true;
}
}
if let Some(open_idx) = trimmed.rfind(FENCE_OPEN) {
let boundary_before = open_idx == 0 || trimmed.as_bytes()[open_idx - 1] == b'\n';
let after_marker = &trimmed[open_idx + FENCE_OPEN.len()..];
let boundary_after = after_marker.chars().next().is_none_or(char::is_whitespace);
let closed_afterward = after_marker.contains(FENCE_CLOSE);
if boundary_before && boundary_after && !closed_afterward {
return true;
}
}
false
}
#[must_use]
pub fn parse_handoff_command(text: &str) -> Option<HandoffCommand> {
let trimmed = text.trim_end();
let body = extract_fenced_body(trimmed)?;
let raw: RawHandoffCommand = serde_json::from_str(body).ok()?;
let mut update = Vec::with_capacity(raw.update.len());
for (key, value) in raw.update {
let value = value.as_str()?.to_owned();
update.push((key, value));
}
Some(HandoffCommand {
goto: parse_task_ref(&raw.goto),
update,
})
}
#[cfg(test)]
mod handoff_command_tests {
use super::*;
#[test]
fn parse_task_ref_numeric_is_by_id() {
assert_eq!(parse_task_ref("3"), TaskRef::ById(TaskId(3)));
}
#[test]
fn parse_task_ref_non_numeric_is_by_title() {
assert_eq!(
parse_task_ref("summarize findings"),
TaskRef::ByTitle("summarize findings".to_string())
);
}
#[test]
fn parse_handoff_command_by_id_with_update() {
let text =
"some output\n```zeph-command\n{\"goto\": \"1\", \"update\": {\"k\": \"v\"}}\n```";
let cmd = parse_handoff_command(text).expect("should parse");
assert_eq!(cmd.goto, TaskRef::ById(TaskId(1)));
assert_eq!(cmd.update, vec![("k".to_string(), "v".to_string())]);
}
#[test]
fn parse_handoff_command_by_title_no_update() {
let text = "```zeph-command\n{\"goto\": \"fallback task\"}\n```";
let cmd = parse_handoff_command(text).expect("should parse");
assert_eq!(cmd.goto, TaskRef::ByTitle("fallback task".to_string()));
assert!(cmd.update.is_empty());
}
#[test]
fn parse_handoff_command_absent_block_returns_none() {
assert!(parse_handoff_command("just plain text output").is_none());
}
#[test]
fn parse_handoff_command_trailing_whitespace_after_fence_is_ok() {
let text = "```zeph-command\n{\"goto\": \"0\"}\n```\n\n \n";
assert!(parse_handoff_command(text).is_some());
}
#[test]
fn parse_handoff_command_extra_text_after_fence_is_malformed() {
let text = "```zeph-command\n{\"goto\": \"0\"}\n```\nsome trailing prose";
assert!(parse_handoff_command(text).is_none());
}
#[test]
fn parse_handoff_command_malformed_json_returns_none() {
let text = "```zeph-command\n{not valid json\n```";
assert!(parse_handoff_command(text).is_none());
}
#[test]
fn parse_handoff_command_missing_goto_returns_none() {
let text = "```zeph-command\n{\"update\": {}}\n```";
assert!(parse_handoff_command(text).is_none());
}
#[test]
fn parse_handoff_command_non_string_update_value_returns_none() {
let text = "```zeph-command\n{\"goto\": \"0\", \"update\": {\"k\": 42}}\n```";
assert!(parse_handoff_command(text).is_none());
}
#[test]
fn parse_handoff_command_similar_fence_name_is_not_matched() {
let text = "```zeph-command-x\n{\"goto\": \"0\"}\n```";
assert!(parse_handoff_command(text).is_none());
}
#[test]
fn parse_handoff_command_no_leading_text_is_ok() {
let text = "```zeph-command\n{\"goto\": \"0\"}\n```";
assert!(parse_handoff_command(text).is_some());
}
#[test]
fn has_handoff_fence_absent_returns_false() {
assert!(!has_handoff_fence("just plain text output"));
}
#[test]
fn has_handoff_fence_well_formed_returns_true() {
let text = "some output\n```zeph-command\n{\"goto\": \"1\"}\n```";
assert!(has_handoff_fence(text));
}
#[test]
fn has_handoff_fence_malformed_json_still_returns_true() {
let text = "```zeph-command\n{not valid json\n```";
assert!(has_handoff_fence(text));
assert!(parse_handoff_command(text).is_none());
}
#[test]
fn has_handoff_fence_closed_block_with_real_trailing_content_is_not_attempted() {
let text = "Let me explain the syntax:\n```zeph-command\n{\"goto\": \"1\"}\n```\n\
Now here is my actual finding: the bug was in auth.rs.";
assert!(!has_handoff_fence(text));
assert!(parse_handoff_command(text).is_none());
}
#[test]
fn has_handoff_fence_no_closing_fence_still_returns_true() {
let text = "```zeph-command\n{\"goto\": \"0\"}";
assert!(has_handoff_fence(text));
assert!(parse_handoff_command(text).is_none());
}
#[test]
fn has_handoff_fence_similar_fence_name_returns_false() {
let text = "```zeph-command-x\n{\"goto\": \"0\"}\n```";
assert!(!has_handoff_fence(text));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::assert_matches;
#[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:?}"
);
}
}