Skip to main content

sc/model/
plan.rs

1//! Plan model for SaveContext.
2//!
3//! Plans represent PRDs, specs, or feature documentation that can be linked
4//! to epics and issues for tracking implementation.
5
6use serde::{Deserialize, Serialize};
7
8/// Plan status values.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum PlanStatus {
12    Draft,
13    Active,
14    Completed,
15}
16
17impl PlanStatus {
18    /// Get the string representation for storage.
19    #[must_use]
20    pub const fn as_str(&self) -> &'static str {
21        match self {
22            Self::Draft => "draft",
23            Self::Active => "active",
24            Self::Completed => "completed",
25        }
26    }
27
28    /// Parse from string.
29    #[must_use]
30    pub fn from_str(s: &str) -> Self {
31        match s.to_lowercase().as_str() {
32            "active" => Self::Active,
33            "completed" => Self::Completed,
34            _ => Self::Draft,
35        }
36    }
37}
38
39impl Default for PlanStatus {
40    fn default() -> Self {
41        Self::Draft
42    }
43}
44
45/// A plan in SaveContext.
46///
47/// Plans provide:
48/// - PRD/specification storage
49/// - Linkage to epics and issues
50/// - Success criteria tracking
51/// - Status progression (draft -> active -> completed)
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Plan {
54    /// Unique identifier (UUID format)
55    pub id: String,
56
57    /// Short ID for easy reference (e.g., "PLAN-1")
58    pub short_id: Option<String>,
59
60    /// Project ID this plan belongs to
61    pub project_id: String,
62
63    /// Project path for queries
64    pub project_path: String,
65
66    /// Plan title
67    pub title: String,
68
69    /// Plan content (markdown PRD/spec)
70    pub content: Option<String>,
71
72    /// Current status
73    pub status: PlanStatus,
74
75    /// Success criteria for completion
76    pub success_criteria: Option<String>,
77
78    /// Session this plan is bound to (TTY-resolved)
79    pub session_id: Option<String>,
80
81    /// Session where this plan was created (legacy metadata)
82    pub created_in_session: Option<String>,
83
84    /// Session where this plan was completed
85    pub completed_in_session: Option<String>,
86
87    /// Source file path (for multi-agent capture dedup)
88    pub source_path: Option<String>,
89
90    /// SHA-256 hash of source file content (for dedup)
91    pub source_hash: Option<String>,
92
93    /// Creation timestamp (Unix milliseconds)
94    pub created_at: i64,
95
96    /// Last update timestamp (Unix milliseconds)
97    pub updated_at: i64,
98
99    /// Completion timestamp (Unix milliseconds)
100    pub completed_at: Option<i64>,
101}
102
103impl Plan {
104    /// Create a new plan with default values.
105    pub fn new(project_id: String, project_path: String, title: String) -> Self {
106        let now = chrono::Utc::now().timestamp_millis();
107        let id = format!("plan_{}", &uuid::Uuid::new_v4().to_string()[..12]);
108
109        Self {
110            id,
111            short_id: None,
112            project_id,
113            project_path,
114            title,
115            content: None,
116            status: PlanStatus::Draft,
117            success_criteria: None,
118            session_id: None,
119            created_in_session: None,
120            completed_in_session: None,
121            source_path: None,
122            source_hash: None,
123            created_at: now,
124            updated_at: now,
125            completed_at: None,
126        }
127    }
128
129    /// Set the plan content.
130    #[must_use]
131    pub fn with_content(mut self, content: &str) -> Self {
132        self.content = Some(content.to_string());
133        self
134    }
135
136    /// Set the plan status.
137    #[must_use]
138    pub fn with_status(mut self, status: PlanStatus) -> Self {
139        self.status = status;
140        self
141    }
142
143    /// Set the success criteria.
144    #[must_use]
145    pub fn with_success_criteria(mut self, criteria: &str) -> Self {
146        self.success_criteria = Some(criteria.to_string());
147        self
148    }
149
150    /// Bind to a session.
151    #[must_use]
152    pub fn with_session(mut self, session_id: &str) -> Self {
153        self.session_id = Some(session_id.to_string());
154        self
155    }
156
157    /// Set the source file path and content hash (for capture dedup).
158    #[must_use]
159    pub fn with_source(mut self, path: &str, hash: &str) -> Self {
160        self.source_path = Some(path.to_string());
161        self.source_hash = Some(hash.to_string());
162        self
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_new_plan() {
172        let plan = Plan::new(
173            "proj_123".to_string(),
174            "/home/user/myproject".to_string(),
175            "Authentication System".to_string(),
176        );
177
178        assert!(plan.id.starts_with("plan_"));
179        assert_eq!(plan.project_id, "proj_123");
180        assert_eq!(plan.title, "Authentication System");
181        assert_eq!(plan.status, PlanStatus::Draft);
182    }
183
184    #[test]
185    fn test_plan_status_parsing() {
186        assert_eq!(PlanStatus::from_str("draft"), PlanStatus::Draft);
187        assert_eq!(PlanStatus::from_str("active"), PlanStatus::Active);
188        assert_eq!(PlanStatus::from_str("completed"), PlanStatus::Completed);
189        assert_eq!(PlanStatus::from_str("unknown"), PlanStatus::Draft);
190    }
191}