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.milestones.iter().filter(|m| m.status.is_terminal()).count() as f32;
116 done / self.milestones.len() as f32
117 }
118
119 #[must_use]
121 pub fn is_complete(&self) -> bool {
122 self.completion_ratio() >= 1.0
123 }
124
125 #[must_use]
127 pub fn is_stalled(&self) -> bool {
128 self.stalled_since.is_some()
129 }
130
131 pub fn note_advance(&mut self) {
134 self.stalled_since = None;
135 self.confidence = (self.confidence + 0.05).min(1.0);
136 self.updated_at = Utc::now().to_rfc3339();
137 }
138
139 pub fn note_stall(&mut self) {
142 if self.stalled_since.is_none() {
143 self.stalled_since = Some(Utc::now().to_rfc3339());
144 }
145 self.confidence = (self.confidence - 0.1).max(0.0);
146 self.updated_at = Utc::now().to_rfc3339();
147 }
148
149 pub fn set_milestones(&mut self, milestones: Vec<Milestone>) {
151 self.milestones = milestones;
152 self.updated_at = Utc::now().to_rfc3339();
153 }
154
155 pub fn set_goal(&mut self, goal: &str) {
157 self.goal = goal.to_string();
158 self.updated_at = Utc::now().to_rfc3339();
159 }
160
161 pub fn set_handoff(
163 &mut self,
164 previous_session_id: &str,
165 summary: &str,
166 git_checkpoint: Option<String>,
167 ) {
168 self.previous_session_id = Some(previous_session_id.to_string());
169 self.handoff_summary = Some(summary.to_string());
170 self.git_checkpoint = git_checkpoint;
171 self.updated_at = Utc::now().to_rfc3339();
172 }
173
174 pub fn add_known_issue(&mut self, issue: &str) {
176 self.known_issues.push(issue.to_string());
177 self.updated_at = Utc::now().to_rfc3339();
178 }
179
180 #[must_use]
185 pub fn to_markdown(&self) -> String {
186 let mut out = String::new();
187 out.push_str("# Session Progress\n\n");
188 out.push_str(&format!("**Goal:** {}\n", self.goal));
189 out.push_str(&format!(
190 "**Completion:** {:.0}%\n",
191 (self.completion_ratio() * 100.0).round()
192 ));
193 out.push_str(&format!("**Confidence:** {:.2}\n", self.confidence));
194 if let Some(since) = &self.stalled_since {
195 out.push_str(&format!("**Stalled since:** {since}\n"));
196 }
197 out.push_str(&format!("**Updated:** {}\n\n", self.updated_at));
198
199 if let Some(prev) = &self.previous_session_id {
200 out.push_str(&format!("**Handed off from:** {prev}\n"));
201 }
202 if let Some(summary) = &self.handoff_summary {
203 out.push_str(&format!("**Handoff summary:** {summary}\n"));
204 }
205 if let Some(checkpoint) = &self.git_checkpoint {
206 out.push_str(&format!("**Git checkpoint:** `{checkpoint}`\n"));
207 }
208 if !self.known_issues.is_empty() {
209 out.push_str("\n## Known Issues\n\n");
210 for issue in &self.known_issues {
211 out.push_str(&format!("- {issue}\n"));
212 }
213 }
214
215 if self.milestones.is_empty() {
216 out.push_str("\n_No tracked milestones yet._\n");
217 } else {
218 out.push_str("\n## Milestones\n\n");
219 for m in &self.milestones {
220 let mark = match m.status {
221 MilestoneStatus::Done => "[x]",
222 MilestoneStatus::InProgress => "[~]",
223 MilestoneStatus::Blocked => "[!]",
224 MilestoneStatus::Pending => "[ ]",
225 };
226 out.push_str(&format!("{} {} — {}\n", mark, m.id, m.description));
227 }
228 }
229 out
230 }
231}
232
233#[must_use]
235pub fn progress_path(workspace: &Path, session_id: &str) -> std::path::PathBuf {
236 session_dir(workspace, session_id)
237 .join(crate::DERIVED_DIR)
238 .join("progress.json")
239}
240
241pub fn load_progress(
246 workspace: &Path,
247 session_id: &str,
248) -> Result<Option<ProgressLedger>, SessionStoreError> {
249 let path = progress_path(workspace, session_id);
250 if !path.exists() {
251 return Ok(None);
252 }
253 let bytes = std::fs::read(&path).map_err(|e| SessionStoreError::io(path.clone(), e))?;
254 let ledger: ProgressLedger = serde_json::from_slice(&bytes)?;
255 Ok(Some(ledger))
256}
257
258pub fn save_progress(
260 workspace: &Path,
261 session_id: &str,
262 ledger: &ProgressLedger,
263) -> Result<(), SessionStoreError> {
264 let path = progress_path(workspace, session_id);
265 if let Some(parent) = path.parent() {
266 std::fs::create_dir_all(parent)
267 .map_err(|e| SessionStoreError::CreateDir { path: parent.to_path_buf(), source: e })?;
268 }
269 let bytes = serde_json::to_string_pretty(ledger)?;
270 std::fs::write(&path, bytes).map_err(|e| SessionStoreError::io(path, e))?;
271 Ok(())
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 fn sample_ledger() -> ProgressLedger {
279 let mut l = ProgressLedger::new("s1", "ship the feature");
280 l.set_milestones(vec![
281 Milestone {
282 id: "1".into(),
283 description: "design".into(),
284 status: MilestoneStatus::Done,
285 },
286 Milestone {
287 id: "2".into(),
288 description: "implement".into(),
289 status: MilestoneStatus::InProgress,
290 },
291 Milestone {
292 id: "3".into(),
293 description: "verify".into(),
294 status: MilestoneStatus::Pending,
295 },
296 ]);
297 l
298 }
299
300 #[test]
301 fn completion_ratio_reflects_terminal_milestones() {
302 let l = sample_ledger();
303 assert!((l.completion_ratio() - 1.0 / 3.0).abs() < f32::EPSILON);
304 assert!(!l.is_complete());
305 }
306
307 #[test]
308 fn empty_ledger_is_complete() {
309 let l = ProgressLedger::new("s", "goal");
310 assert!(l.is_complete());
311 assert!((l.completion_ratio() - 1.0).abs() < f32::EPSILON);
312 }
313
314 #[test]
315 fn advance_clears_stall_and_bumps_confidence() {
316 let mut l = sample_ledger();
317 l.note_stall();
318 assert!(l.is_stalled());
319 let before = l.confidence;
320 l.note_advance();
321 assert!(!l.is_stalled());
322 assert!(l.confidence >= before);
323 }
324
325 #[test]
326 fn persistence_round_trips() {
327 let tmp = std::env::temp_dir().join(format!("vtcode-prog-{}", std::process::id()));
328 let ws = tmp.join("ws");
329 std::fs::create_dir_all(&ws).unwrap();
330 let mut l = sample_ledger();
331 l.note_stall();
332 save_progress(&ws, "s1", &l).unwrap();
333 let loaded = load_progress(&ws, "s1").unwrap().expect("ledger present");
334 assert_eq!(loaded, l);
335 assert!(loaded.is_stalled());
336 assert!(load_progress(&ws, "absent").unwrap().is_none());
338 let _ = std::fs::remove_dir_all(&tmp);
339 }
340
341 #[test]
342 fn handoff_metadata_defaults_to_none() {
343 let l = ProgressLedger::new("s1", "goal");
344 assert!(l.previous_session_id.is_none());
345 assert!(l.handoff_summary.is_none());
346 assert!(l.known_issues.is_empty());
347 assert!(l.git_checkpoint.is_none());
348 }
349
350 #[test]
351 fn set_handoff_records_metadata() {
352 let mut l = ProgressLedger::new("s2", "goal");
353 l.set_handoff("s1", "implemented login", Some("abc123".to_string()));
354 assert_eq!(l.previous_session_id.as_deref(), Some("s1"));
355 assert_eq!(l.handoff_summary.as_deref(), Some("implemented login"));
356 assert_eq!(l.git_checkpoint.as_deref(), Some("abc123"));
357 }
358
359 #[test]
360 fn add_known_issue_accumulates() {
361 let mut l = ProgressLedger::new("s3", "goal");
362 l.add_known_issue("rate limiting missing");
363 l.add_known_issue("no error handling for timeouts");
364 assert_eq!(l.known_issues.len(), 2);
365 assert_eq!(l.known_issues[0], "rate limiting missing");
366 }
367
368 #[test]
369 fn handoff_metadata_survives_persistence() {
370 let tmp = std::env::temp_dir().join(format!("vtcode-prog-{}", std::process::id()));
371 let ws = tmp.join("ws");
372 std::fs::create_dir_all(&ws).unwrap();
373
374 let mut l = sample_ledger();
375 l.set_handoff("prev-session", "built auth", Some("def456".to_string()));
376 l.add_known_issue("tests are flaky");
377
378 save_progress(&ws, "s4", &l).unwrap();
379 let loaded = load_progress(&ws, "s4").unwrap().expect("present");
380 assert_eq!(loaded.previous_session_id.as_deref(), Some("prev-session"));
381 assert_eq!(loaded.handoff_summary.as_deref(), Some("built auth"));
382 assert_eq!(loaded.git_checkpoint.as_deref(), Some("def456"));
383 assert_eq!(loaded.known_issues, vec!["tests are flaky"]);
384
385 let _ = std::fs::remove_dir_all(&tmp);
386 }
387
388 #[test]
389 fn to_markdown_includes_handoff_metadata() {
390 let mut l = ProgressLedger::new("s5", "build feature");
391 l.set_handoff("s4", "implemented core", Some("abc123".to_string()));
392 l.add_known_issue("missing error handling");
393
394 let md = l.to_markdown();
395 assert!(md.contains("Handed off from:** s4"));
396 assert!(md.contains("Handoff summary:** implemented core"));
397 assert!(md.contains("Git checkpoint:** `abc123`"));
398 assert!(md.contains("- missing error handling"));
399 }
400
401 #[test]
402 fn to_markdown_omits_handoff_when_absent() {
403 let l = ProgressLedger::new("s6", "goal");
404 let md = l.to_markdown();
405 assert!(!md.contains("Handed off from"));
406 assert!(!md.contains("Handoff summary"));
407 assert!(!md.contains("Git checkpoint"));
408 assert!(!md.contains("Known Issues"));
409 }
410}