Skip to main content

clawft_types/
goal.rs

1//! Goal alignment types for the Paperclip Patterns integration.
2//!
3//! Models hierarchical goals with parent-child relationships, status
4//! tracking, and key metrics. Used alongside the resource tree and
5//! org-chart to align agent behaviour with business objectives.
6
7use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10
11/// Status of a goal in its lifecycle.
12#[non_exhaustive]
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum GoalStatus {
16    /// Goal has been defined but not yet started.
17    Pending,
18    /// Goal is actively being pursued.
19    Active,
20    /// Goal has been achieved.
21    Complete,
22    /// Goal was abandoned or could not be met.
23    Failed,
24}
25
26impl Default for GoalStatus {
27    fn default() -> Self {
28        Self::Pending
29    }
30}
31
32impl std::fmt::Display for GoalStatus {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            GoalStatus::Pending => write!(f, "pending"),
36            GoalStatus::Active => write!(f, "active"),
37            GoalStatus::Complete => write!(f, "complete"),
38            GoalStatus::Failed => write!(f, "failed"),
39        }
40    }
41}
42
43/// A single goal in a goal hierarchy.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Goal {
46    /// Unique goal identifier.
47    pub id: String,
48    /// Human-readable description of the goal.
49    pub description: String,
50    /// Parent goal ID (`None` for top-level goals).
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub parent_goal: Option<String>,
53    /// Current status.
54    #[serde(default)]
55    pub status: GoalStatus,
56    /// Key-value metrics for tracking progress (e.g. "completion_pct" -> "75").
57    #[serde(default)]
58    pub metrics: HashMap<String, String>,
59}
60
61impl Goal {
62    /// Create a new top-level goal in pending status.
63    pub fn new(id: impl Into<String>, description: impl Into<String>) -> Self {
64        Self {
65            id: id.into(),
66            description: description.into(),
67            parent_goal: None,
68            status: GoalStatus::Pending,
69            metrics: HashMap::new(),
70        }
71    }
72
73    /// Create a sub-goal under the given parent.
74    pub fn child(
75        id: impl Into<String>,
76        description: impl Into<String>,
77        parent_id: impl Into<String>,
78    ) -> Self {
79        Self {
80            id: id.into(),
81            description: description.into(),
82            parent_goal: Some(parent_id.into()),
83            status: GoalStatus::Pending,
84            metrics: HashMap::new(),
85        }
86    }
87}
88
89/// A tree of goals with parent-child traversal.
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct GoalTree {
92    /// All goals in the tree (flat storage, linked by `parent_goal`).
93    pub goals: Vec<Goal>,
94}
95
96impl GoalTree {
97    /// Create an empty goal tree.
98    pub fn new() -> Self {
99        Self { goals: Vec::new() }
100    }
101
102    /// Add a goal to the tree.
103    pub fn add(&mut self, goal: Goal) {
104        self.goals.push(goal);
105    }
106
107    /// Find a goal by ID.
108    pub fn find(&self, id: &str) -> Option<&Goal> {
109        self.goals.iter().find(|g| g.id == id)
110    }
111
112    /// Find a goal by ID (mutable).
113    pub fn find_mut(&mut self, id: &str) -> Option<&mut Goal> {
114        self.goals.iter_mut().find(|g| g.id == id)
115    }
116
117    /// Return all root goals (those with no parent).
118    pub fn roots(&self) -> Vec<&Goal> {
119        self.goals.iter().filter(|g| g.parent_goal.is_none()).collect()
120    }
121
122    /// Return all direct children of the given goal.
123    pub fn children(&self, parent_id: &str) -> Vec<&Goal> {
124        self.goals
125            .iter()
126            .filter(|g| g.parent_goal.as_deref() == Some(parent_id))
127            .collect()
128    }
129
130    /// Return all descendants of the given goal (depth-first).
131    pub fn descendants(&self, parent_id: &str) -> Vec<&Goal> {
132        let mut result = Vec::new();
133        let mut stack: Vec<&str> = vec![parent_id];
134        while let Some(current) = stack.pop() {
135            for child in self.children(current) {
136                result.push(child);
137                stack.push(&child.id);
138            }
139        }
140        result
141    }
142
143    /// Return goals filtered by status.
144    pub fn by_status(&self, status: GoalStatus) -> Vec<&Goal> {
145        self.goals.iter().filter(|g| g.status == status).collect()
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn goal_status_default_is_pending() {
155        assert_eq!(GoalStatus::default(), GoalStatus::Pending);
156    }
157
158    #[test]
159    fn goal_status_serde() {
160        let statuses = [
161            (GoalStatus::Pending, "\"pending\""),
162            (GoalStatus::Active, "\"active\""),
163            (GoalStatus::Complete, "\"complete\""),
164            (GoalStatus::Failed, "\"failed\""),
165        ];
166        for (status, expected) in &statuses {
167            let json = serde_json::to_string(status).unwrap();
168            assert_eq!(&json, expected);
169            let restored: GoalStatus = serde_json::from_str(&json).unwrap();
170            assert_eq!(&restored, status);
171        }
172    }
173
174    #[test]
175    fn goal_status_display() {
176        assert_eq!(GoalStatus::Active.to_string(), "active");
177        assert_eq!(GoalStatus::Failed.to_string(), "failed");
178    }
179
180    #[test]
181    fn goal_new_top_level() {
182        let g = Goal::new("g1", "Increase revenue");
183        assert_eq!(g.id, "g1");
184        assert!(g.parent_goal.is_none());
185        assert_eq!(g.status, GoalStatus::Pending);
186        assert!(g.metrics.is_empty());
187    }
188
189    #[test]
190    fn goal_child() {
191        let g = Goal::child("g2", "Hire sales team", "g1");
192        assert_eq!(g.parent_goal.as_deref(), Some("g1"));
193    }
194
195    #[test]
196    fn goal_serde_roundtrip() {
197        let mut g = Goal::new("g1", "Ship v2");
198        g.status = GoalStatus::Active;
199        g.metrics.insert("pct".into(), "50".into());
200        let json = serde_json::to_string(&g).unwrap();
201        let restored: Goal = serde_json::from_str(&json).unwrap();
202        assert_eq!(restored.id, "g1");
203        assert_eq!(restored.status, GoalStatus::Active);
204        assert_eq!(restored.metrics.get("pct").unwrap(), "50");
205    }
206
207    #[test]
208    fn goal_omits_none_parent() {
209        let g = Goal::new("g1", "top");
210        let json = serde_json::to_string(&g).unwrap();
211        assert!(!json.contains("parent_goal"));
212    }
213
214    #[test]
215    fn goal_tree_empty() {
216        let tree = GoalTree::new();
217        assert!(tree.goals.is_empty());
218        assert!(tree.roots().is_empty());
219    }
220
221    #[test]
222    fn goal_tree_add_and_find() {
223        let mut tree = GoalTree::new();
224        tree.add(Goal::new("g1", "Revenue"));
225        assert!(tree.find("g1").is_some());
226        assert!(tree.find("nonexistent").is_none());
227    }
228
229    #[test]
230    fn goal_tree_find_mut() {
231        let mut tree = GoalTree::new();
232        tree.add(Goal::new("g1", "Revenue"));
233        tree.find_mut("g1").unwrap().status = GoalStatus::Complete;
234        assert_eq!(tree.find("g1").unwrap().status, GoalStatus::Complete);
235    }
236
237    #[test]
238    fn goal_tree_roots_and_children() {
239        let mut tree = GoalTree::new();
240        tree.add(Goal::new("root1", "Strategic goal A"));
241        tree.add(Goal::new("root2", "Strategic goal B"));
242        tree.add(Goal::child("sub1", "Sub-goal 1", "root1"));
243        tree.add(Goal::child("sub2", "Sub-goal 2", "root1"));
244        tree.add(Goal::child("sub3", "Sub-goal 3", "root2"));
245
246        let roots = tree.roots();
247        assert_eq!(roots.len(), 2);
248
249        let children = tree.children("root1");
250        assert_eq!(children.len(), 2);
251        assert!(children.iter().any(|g| g.id == "sub1"));
252        assert!(children.iter().any(|g| g.id == "sub2"));
253
254        let r2_children = tree.children("root2");
255        assert_eq!(r2_children.len(), 1);
256    }
257
258    #[test]
259    fn goal_tree_descendants() {
260        let mut tree = GoalTree::new();
261        tree.add(Goal::new("r", "Root"));
262        tree.add(Goal::child("a", "Child A", "r"));
263        tree.add(Goal::child("b", "Child B", "r"));
264        tree.add(Goal::child("a1", "Grandchild A1", "a"));
265        tree.add(Goal::child("a2", "Grandchild A2", "a"));
266
267        let desc = tree.descendants("r");
268        assert_eq!(desc.len(), 4);
269
270        let a_desc = tree.descendants("a");
271        assert_eq!(a_desc.len(), 2);
272
273        let leaf_desc = tree.descendants("a1");
274        assert!(leaf_desc.is_empty());
275    }
276
277    #[test]
278    fn goal_tree_by_status() {
279        let mut tree = GoalTree::new();
280        let mut g1 = Goal::new("g1", "Active goal");
281        g1.status = GoalStatus::Active;
282        let mut g2 = Goal::new("g2", "Complete goal");
283        g2.status = GoalStatus::Complete;
284        tree.add(g1);
285        tree.add(g2);
286        tree.add(Goal::new("g3", "Pending goal"));
287
288        assert_eq!(tree.by_status(GoalStatus::Active).len(), 1);
289        assert_eq!(tree.by_status(GoalStatus::Pending).len(), 1);
290        assert_eq!(tree.by_status(GoalStatus::Complete).len(), 1);
291        assert_eq!(tree.by_status(GoalStatus::Failed).len(), 0);
292    }
293
294    #[test]
295    fn goal_tree_serde_roundtrip() {
296        let mut tree = GoalTree::new();
297        tree.add(Goal::new("g1", "Top"));
298        tree.add(Goal::child("g2", "Sub", "g1"));
299        let json = serde_json::to_string(&tree).unwrap();
300        let restored: GoalTree = serde_json::from_str(&json).unwrap();
301        assert_eq!(restored.goals.len(), 2);
302        assert_eq!(restored.find("g2").unwrap().parent_goal.as_deref(), Some("g1"));
303    }
304}