codeswarm_adapters/
goal.rs1use serde::{Deserialize, Serialize};
3
4pub const GOAL_USAGE: &str = "/goal [OBJECTIVE | run | done | clear]";
5
6#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
7#[serde(rename_all = "snake_case")]
8pub enum GoalStatus {
9 Active,
10 Completed,
11}
12
13#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
14pub struct Goal {
15 pub objective: String,
16 pub status: GoalStatus,
17}
18
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub enum GoalCommand {
21 Show,
22 Set(String),
23 Run,
24 Done,
25 Clear,
26}
27
28impl GoalCommand {
29 pub fn parse(argument: &str) -> Result<Self, String> {
30 let text = argument.trim();
31 let mut parts = text.split_whitespace();
32 let Some(first) = parts.next() else {
33 return Ok(Self::Show);
34 };
35 let reserved = match first.to_ascii_lowercase().as_str() {
36 "run" => Some(Self::Run),
37 "done" => Some(Self::Done),
38 "clear" => Some(Self::Clear),
39 _ => None,
40 };
41 if let Some(command) = reserved {
42 return if parts.next().is_none() {
43 Ok(command)
44 } else {
45 Err(format!("usage: {GOAL_USAGE}"))
46 };
47 }
48 validate_objective(text)?;
49 Ok(Self::Set(text.into()))
50 }
51}
52
53fn validate_objective(text: &str) -> Result<(), String> {
54 if text.trim().is_empty() {
55 return Err("goal objective must not be empty".into());
56 }
57 if text.len() > 16_000 {
58 return Err("goal objective must be at most 16,000 bytes".into());
59 }
60 Ok(())
61}
62
63impl Goal {
64 pub fn from_metadata(value: &serde_json::Value) -> Option<Self> {
65 let goal: Self = serde_json::from_value(value.clone()).ok()?;
66 validate_objective(&goal.objective).ok()?;
67 Some(goal)
68 }
69 pub fn summary(&self) -> String {
70 format!(
71 "Goal {}: {}",
72 match self.status {
73 GoalStatus::Active => "active",
74 GoalStatus::Completed => "completed",
75 },
76 self.objective
77 )
78 }
79}
80
81pub fn apply(goal: &mut Option<Goal>, command: GoalCommand) -> Result<Option<String>, String> {
83 match command {
84 GoalCommand::Set(objective) => {
85 validate_objective(&objective)?;
86 let task = format!("Work toward this goal: {}", objective.trim());
87 *goal = Some(Goal {
88 objective: objective.trim().into(),
89 status: GoalStatus::Active,
90 });
91 Ok(Some(task))
92 }
93 GoalCommand::Run => match goal {
94 Some(goal) if goal.status == GoalStatus::Active => Ok(Some(format!(
95 "Continue working toward this goal: {}",
96 goal.objective
97 ))),
98 _ => Err("no active goal; use /goal OBJECTIVE to start one".into()),
99 },
100 GoalCommand::Done => {
101 let goal = goal.as_mut().ok_or("no goal to complete")?;
102 goal.status = GoalStatus::Completed;
103 Ok(None)
104 }
105 GoalCommand::Clear => {
106 *goal = None;
107 Ok(None)
108 }
109 GoalCommand::Show => Ok(None),
110 }
111}
112
113pub fn prompt(goal: Option<&Goal>, task: &str) -> String {
114 let context = match goal {
115 Some(goal) if goal.status == GoalStatus::Active => format!(
116 "Active shared goal: {}\nWork toward this objective. The current user request takes priority. Report progress and remaining work honestly; completion is tracked by the user with /goal done. Respect permissions and relay limits.",
117 goal.objective
118 ),
119 _ => "No active shared goal. Follow the current user request.".into(),
120 };
121 format!("{task}\n\n[CodeSwarm goal context]\n{context}")
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 #[test]
128 fn goal_lifecycle_and_invalid_metadata_are_explicit() {
129 let mut goal = None;
130 assert!(apply(&mut goal, GoalCommand::Run).is_err());
131 assert!(apply(&mut goal, GoalCommand::Done).is_err());
132 assert_eq!(GoalCommand::parse("").unwrap(), GoalCommand::Show);
133 assert!(GoalCommand::parse("run extra").is_err());
134 assert!(GoalCommand::parse(&"x".repeat(16_001)).is_err());
135 let objective = "Fix login\n preserve existing sessions";
136 let action = GoalCommand::parse(objective).unwrap();
137 assert!(
138 apply(&mut goal, action)
139 .unwrap()
140 .unwrap()
141 .contains(objective)
142 );
143 let encoded = serde_json::to_value(&goal).unwrap();
144 assert_eq!(Goal::from_metadata(&encoded), goal);
145 assert!(prompt(goal.as_ref(), "review").contains(objective));
146 apply(&mut goal, GoalCommand::Done).unwrap();
147 assert!(apply(&mut goal, GoalCommand::Run).is_err());
148 assert!(!prompt(goal.as_ref(), "new request").contains(objective));
149 apply(&mut goal, GoalCommand::Clear).unwrap();
150 assert!(goal.is_none());
151 for value in [
152 serde_json::json!(null),
153 serde_json::json!({"objective":"", "status":"active"}),
154 serde_json::json!({"objective":"task", "status":"unknown"}),
155 serde_json::json!({"objective":42,"status":"active"}),
156 ] {
157 assert!(Goal::from_metadata(&value).is_none());
158 }
159 }
160}