1use chrono::Utc;
14use serde::{Deserialize, Serialize};
15use std::path::Path;
16
17use crate::error::SessionStoreError;
18use crate::session_dir;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum MilestoneStatus {
24 Pending,
26 InProgress,
28 Done,
30 Blocked,
32}
33
34impl MilestoneStatus {
35 #[must_use]
37 pub fn is_terminal(&self) -> bool {
38 matches!(self, MilestoneStatus::Done)
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct Milestone {
45 pub id: String,
47 pub description: String,
49 pub status: MilestoneStatus,
51}
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60pub struct ProgressLedger {
61 pub session_id: String,
63 pub goal: String,
65 pub milestones: Vec<Milestone>,
67 pub confidence: f32,
69 pub stalled_since: Option<String>,
72 pub updated_at: String,
74 #[serde(default)]
77 pub previous_session_id: Option<String>,
78 #[serde(default)]
80 pub handoff_summary: Option<String>,
81 #[serde(default)]
83 pub known_issues: Vec<String>,
84 #[serde(default)]
86 pub git_checkpoint: Option<String>,
87}
88
89impl ProgressLedger {
90 #[must_use]
92 pub fn new(session_id: &str, goal: &str) -> Self {
93 let ts = Utc::now().to_rfc3339();
94 Self {
95 session_id: session_id.to_string(),
96 goal: goal.to_string(),
97 milestones: Vec::new(),
98 confidence: 1.0,
99 stalled_since: None,
100 updated_at: ts,
101 previous_session_id: None,
102 handoff_summary: None,
103 known_issues: Vec::new(),
104 git_checkpoint: None,
105 }
106 }
107
108 #[must_use]
111 pub fn completion_ratio(&self) -> f32 {
112 if self.milestones.is_empty() {
113 return 1.0;
114 }
115 let done = self
116 .milestones
117 .iter()
118 .filter(|m| m.status.is_terminal())
119 .count() as f32;
120 done / self.milestones.len() as f32
121 }
122
123 #[must_use]
125 pub fn is_complete(&self) -> bool {
126 self.completion_ratio() >= 1.0
127 }
128
129 #[must_use]
131 pub fn is_stalled(&self) -> bool {
132 self.stalled_since.is_some()
133 }
134
135 pub fn note_advance(&mut self) {
138 self.stalled_since = None;
139 self.confidence = (self.confidence + 0.05).min(1.0);
140 self.updated_at = Utc::now().to_rfc3339();
141 }
142
143 pub fn note_stall(&mut self) {
146 if self.stalled_since.is_none() {
147 self.stalled_since = Some(Utc::now().to_rfc3339());
148 }
149 self.confidence = (self.confidence - 0.1).max(0.0);
150 self.updated_at = Utc::now().to_rfc3339();
151 }
152
153 pub fn set_milestones(&mut self, milestones: Vec<Milestone>) {
155 self.milestones = milestones;
156 self.updated_at = Utc::now().to_rfc3339();
157 }
158
159 pub fn set_goal(&mut self, goal: &str) {
161 self.goal = goal.to_string();
162 self.updated_at = Utc::now().to_rfc3339();
163 }
164
165 pub fn set_handoff(
167 &mut self,
168 previous_session_id: &str,
169 summary: &str,
170 git_checkpoint: Option<String>,
171 ) {
172 self.previous_session_id = Some(previous_session_id.to_string());
173 self.handoff_summary = Some(summary.to_string());
174 self.git_checkpoint = git_checkpoint;
175 self.updated_at = Utc::now().to_rfc3339();
176 }
177
178 pub fn add_known_issue(&mut self, issue: &str) {
180 self.known_issues.push(issue.to_string());
181 self.updated_at = Utc::now().to_rfc3339();
182 }
183
184 #[must_use]
189 pub fn to_markdown(&self) -> String {
190 let mut out = String::new();
191 out.push_str("# Session Progress\n\n");
192 out.push_str(&format!("**Goal:** {}\n", self.goal));
193 out.push_str(&format!(
194 "**Completion:** {:.0}%\n",
195 (self.completion_ratio() * 100.0).round()
196 ));
197 out.push_str(&format!("**Confidence:** {:.2}\n", self.confidence));
198 if let Some(since) = &self.stalled_since {
199 out.push_str(&format!("**Stalled since:** {since}\n"));
200 }
201 out.push_str(&format!("**Updated:** {}\n\n", self.updated_at));
202
203 if let Some(prev) = &self.previous_session_id {
204 out.push_str(&format!("**Handed off from:** {prev}\n"));
205 }
206 if let Some(summary) = &self.handoff_summary {
207 out.push_str(&format!("**Handoff summary:** {summary}\n"));
208 }
209 if let Some(checkpoint) = &self.git_checkpoint {
210 out.push_str(&format!("**Git checkpoint:** `{checkpoint}`\n"));
211 }
212 if !self.known_issues.is_empty() {
213 out.push_str("\n## Known Issues\n\n");
214 for issue in &self.known_issues {
215 out.push_str(&format!("- {issue}\n"));
216 }
217 }
218
219 if self.milestones.is_empty() {
220 out.push_str("\n_No tracked milestones yet._\n");
221 } else {
222 out.push_str("\n## Milestones\n\n");
223 for m in &self.milestones {
224 let mark = match m.status {
225 MilestoneStatus::Done => "[x]",
226 MilestoneStatus::InProgress => "[~]",
227 MilestoneStatus::Blocked => "[!]",
228 MilestoneStatus::Pending => "[ ]",
229 };
230 out.push_str(&format!("{} {} — {}\n", mark, m.id, m.description));
231 }
232 }
233 out
234 }
235}
236
237#[must_use]
239pub fn progress_path(workspace: &Path, session_id: &str) -> std::path::PathBuf {
240 session_dir(workspace, session_id)
241 .join(crate::DERIVED_DIR)
242 .join("progress.json")
243}
244
245pub fn load_progress(
250 workspace: &Path,
251 session_id: &str,
252) -> Result<Option<ProgressLedger>, SessionStoreError> {
253 let path = progress_path(workspace, session_id);
254 if !path.exists() {
255 return Ok(None);
256 }
257 let bytes = std::fs::read(&path).map_err(|e| SessionStoreError::io(path.clone(), e))?;
258 let ledger: ProgressLedger = serde_json::from_slice(&bytes)?;
259 Ok(Some(ledger))
260}
261
262pub fn save_progress(
264 workspace: &Path,
265 session_id: &str,
266 ledger: &ProgressLedger,
267) -> Result<(), SessionStoreError> {
268 let path = progress_path(workspace, session_id);
269 if let Some(parent) = path.parent() {
270 std::fs::create_dir_all(parent).map_err(|e| SessionStoreError::CreateDir {
271 path: parent.to_path_buf(),
272 source: e,
273 })?;
274 }
275 let bytes = serde_json::to_string_pretty(ledger)?;
276 std::fs::write(&path, bytes).map_err(|e| SessionStoreError::io(path, e))?;
277 Ok(())
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 fn sample_ledger() -> ProgressLedger {
285 let mut l = ProgressLedger::new("s1", "ship the feature");
286 l.set_milestones(vec![
287 Milestone {
288 id: "1".into(),
289 description: "design".into(),
290 status: MilestoneStatus::Done,
291 },
292 Milestone {
293 id: "2".into(),
294 description: "implement".into(),
295 status: MilestoneStatus::InProgress,
296 },
297 Milestone {
298 id: "3".into(),
299 description: "verify".into(),
300 status: MilestoneStatus::Pending,
301 },
302 ]);
303 l
304 }
305
306 #[test]
307 fn completion_ratio_reflects_terminal_milestones() {
308 let l = sample_ledger();
309 assert!((l.completion_ratio() - 1.0 / 3.0).abs() < f32::EPSILON);
310 assert!(!l.is_complete());
311 }
312
313 #[test]
314 fn empty_ledger_is_complete() {
315 let l = ProgressLedger::new("s", "goal");
316 assert!(l.is_complete());
317 assert!((l.completion_ratio() - 1.0).abs() < f32::EPSILON);
318 }
319
320 #[test]
321 fn advance_clears_stall_and_bumps_confidence() {
322 let mut l = sample_ledger();
323 l.note_stall();
324 assert!(l.is_stalled());
325 let before = l.confidence;
326 l.note_advance();
327 assert!(!l.is_stalled());
328 assert!(l.confidence >= before);
329 }
330
331 #[test]
332 fn persistence_round_trips() {
333 let tmp = std::env::temp_dir().join(format!("vtcode-prog-{}", std::process::id()));
334 let ws = tmp.join("ws");
335 std::fs::create_dir_all(&ws).unwrap();
336 let mut l = sample_ledger();
337 l.note_stall();
338 save_progress(&ws, "s1", &l).unwrap();
339 let loaded = load_progress(&ws, "s1").unwrap().expect("ledger present");
340 assert_eq!(loaded, l);
341 assert!(loaded.is_stalled());
342 assert!(load_progress(&ws, "absent").unwrap().is_none());
344 let _ = std::fs::remove_dir_all(&tmp);
345 }
346
347 #[test]
348 fn handoff_metadata_defaults_to_none() {
349 let l = ProgressLedger::new("s1", "goal");
350 assert!(l.previous_session_id.is_none());
351 assert!(l.handoff_summary.is_none());
352 assert!(l.known_issues.is_empty());
353 assert!(l.git_checkpoint.is_none());
354 }
355
356 #[test]
357 fn set_handoff_records_metadata() {
358 let mut l = ProgressLedger::new("s2", "goal");
359 l.set_handoff("s1", "implemented login", Some("abc123".to_string()));
360 assert_eq!(l.previous_session_id.as_deref(), Some("s1"));
361 assert_eq!(l.handoff_summary.as_deref(), Some("implemented login"));
362 assert_eq!(l.git_checkpoint.as_deref(), Some("abc123"));
363 }
364
365 #[test]
366 fn add_known_issue_accumulates() {
367 let mut l = ProgressLedger::new("s3", "goal");
368 l.add_known_issue("rate limiting missing");
369 l.add_known_issue("no error handling for timeouts");
370 assert_eq!(l.known_issues.len(), 2);
371 assert_eq!(l.known_issues[0], "rate limiting missing");
372 }
373
374 #[test]
375 fn handoff_metadata_survives_persistence() {
376 let tmp = std::env::temp_dir().join(format!("vtcode-prog-{}", std::process::id()));
377 let ws = tmp.join("ws");
378 std::fs::create_dir_all(&ws).unwrap();
379
380 let mut l = sample_ledger();
381 l.set_handoff("prev-session", "built auth", Some("def456".to_string()));
382 l.add_known_issue("tests are flaky");
383
384 save_progress(&ws, "s4", &l).unwrap();
385 let loaded = load_progress(&ws, "s4").unwrap().expect("present");
386 assert_eq!(loaded.previous_session_id.as_deref(), Some("prev-session"));
387 assert_eq!(loaded.handoff_summary.as_deref(), Some("built auth"));
388 assert_eq!(loaded.git_checkpoint.as_deref(), Some("def456"));
389 assert_eq!(loaded.known_issues, vec!["tests are flaky"]);
390
391 let _ = std::fs::remove_dir_all(&tmp);
392 }
393
394 #[test]
395 fn to_markdown_includes_handoff_metadata() {
396 let mut l = ProgressLedger::new("s5", "build feature");
397 l.set_handoff("s4", "implemented core", Some("abc123".to_string()));
398 l.add_known_issue("missing error handling");
399
400 let md = l.to_markdown();
401 assert!(md.contains("Handed off from:** s4"));
402 assert!(md.contains("Handoff summary:** implemented core"));
403 assert!(md.contains("Git checkpoint:** `abc123`"));
404 assert!(md.contains("- missing error handling"));
405 }
406
407 #[test]
408 fn to_markdown_omits_handoff_when_absent() {
409 let l = ProgressLedger::new("s6", "goal");
410 let md = l.to_markdown();
411 assert!(!md.contains("Handed off from"));
412 assert!(!md.contains("Handoff summary"));
413 assert!(!md.contains("Git checkpoint"));
414 assert!(!md.contains("Known Issues"));
415 }
416}