1use anyhow::{Context, Result};
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::Path;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ProgressEntry {
18 pub timestamp: DateTime<Utc>,
20 pub entry_type: EntryType,
22 pub step_id: String,
24 pub title: String,
26 pub details: String,
28 #[serde(default)]
30 pub metadata: HashMap<String, serde_json::Value>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum EntryType {
37 StoryStart,
39 StoryComplete,
41 PatternDiscovered,
43 TestResults,
45 BuildResults,
47 Decision,
49 Risk,
51 Milestone,
53 Error,
55 Info,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct CodebasePattern {
62 pub category: String,
64 pub description: String,
66 pub example: Option<String>,
68 pub reusable: bool,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct TestSnapshot {
75 pub command: String,
77 pub output: String,
79 pub passed: bool,
81 #[serde(default)]
83 pub failure_count: Option<usize>,
84 pub duration_secs: f64,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct BuildSnapshot {
91 pub command: String,
93 pub output: String,
95 pub succeeded: bool,
97 #[serde(default)]
99 pub errors: Vec<String>,
100 #[serde(default)]
102 pub warnings: Vec<String>,
103 pub duration_secs: f64,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct Decision {
110 pub title: String,
112 pub context: String,
114 pub decision: String,
116 pub consequences: Vec<String>,
118 #[serde(default)]
120 pub alternatives: Vec<String>,
121 pub reversible: bool,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct Risk {
128 pub description: String,
130 pub level: RiskLevel,
132 pub mitigation: Option<String>,
134 #[serde(default)]
136 pub resolved: bool,
137 #[serde(default)]
139 pub resolution_notes: Option<String>,
140}
141
142#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
144#[serde(rename_all = "lowercase")]
145pub enum RiskLevel {
146 Low,
147 Medium,
148 High,
149 Critical,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct ProgressJournal {
155 pub run_id: String,
157 pub workflow_name: String,
159 pub start_time: DateTime<Utc>,
161 pub last_update: DateTime<Utc>,
163 pub task: String,
165 pub repo: Option<String>,
167 pub branch: Option<String>,
169 pub entries: Vec<ProgressEntry>,
171 #[serde(default)]
173 pub patterns: Vec<CodebasePattern>,
174 #[serde(default)]
176 pub decisions: Vec<Decision>,
177 #[serde(default)]
179 pub risks: Vec<Risk>,
180 #[serde(default)]
182 pub test_snapshots: Vec<TestSnapshot>,
183 #[serde(default)]
185 pub build_snapshots: Vec<BuildSnapshot>,
186 #[serde(default)]
188 pub sections: HashMap<String, String>,
189}
190
191impl ProgressJournal {
192 pub fn new(run_id: String, workflow_name: String, task: String) -> Self {
194 let now = Utc::now();
195 Self {
196 run_id,
197 workflow_name,
198 start_time: now,
199 last_update: now,
200 task,
201 repo: None,
202 branch: None,
203 entries: vec![],
204 patterns: vec![],
205 decisions: vec![],
206 risks: vec![],
207 test_snapshots: vec![],
208 build_snapshots: vec![],
209 sections: HashMap::new(),
210 }
211 }
212
213 pub fn add_entry(&mut self, entry_type: EntryType, step_id: &str, title: &str, details: &str) {
215 self.entries.push(ProgressEntry {
216 timestamp: Utc::now(),
217 entry_type,
218 step_id: step_id.to_string(),
219 title: title.to_string(),
220 details: details.to_string(),
221 metadata: HashMap::new(),
222 });
223 self.last_update = Utc::now();
224 }
225
226 pub fn add_pattern(
228 &mut self,
229 category: &str,
230 description: &str,
231 example: Option<&str>,
232 reusable: bool,
233 ) {
234 self.patterns.push(CodebasePattern {
235 category: category.to_string(),
236 description: description.to_string(),
237 example: example.map(|s| s.to_string()),
238 reusable,
239 });
240 }
241
242 pub fn add_decision(
244 &mut self,
245 title: &str,
246 context: &str,
247 decision: &str,
248 consequences: Vec<String>,
249 reversible: bool,
250 ) {
251 self.decisions.push(Decision {
252 title: title.to_string(),
253 context: context.to_string(),
254 decision: decision.to_string(),
255 consequences,
256 alternatives: vec![],
257 reversible,
258 });
259 }
260
261 pub fn add_risk(&mut self, description: &str, level: RiskLevel, mitigation: Option<&str>) {
263 self.risks.push(Risk {
264 description: description.to_string(),
265 level,
266 mitigation: mitigation.map(|s| s.to_string()),
267 resolved: false,
268 resolution_notes: None,
269 });
270 }
271
272 pub fn resolve_risk(&mut self, description: &str, notes: &str) {
274 if let Some(risk) = self.risks.iter_mut().find(|r| r.description == description) {
275 risk.resolved = true;
276 risk.resolution_notes = Some(notes.to_string());
277 }
278 }
279
280 pub fn add_test_snapshot(
282 &mut self,
283 command: &str,
284 output: &str,
285 passed: bool,
286 duration_secs: f64,
287 ) {
288 self.test_snapshots.push(TestSnapshot {
289 command: command.to_string(),
290 output: output.to_string(),
291 passed,
292 failure_count: None,
293 duration_secs,
294 });
295 }
296
297 pub fn add_build_snapshot(
299 &mut self,
300 command: &str,
301 output: &str,
302 succeeded: bool,
303 duration_secs: f64,
304 ) {
305 self.build_snapshots.push(BuildSnapshot {
306 command: command.to_string(),
307 output: output.to_string(),
308 succeeded,
309 errors: vec![],
310 warnings: vec![],
311 duration_secs,
312 });
313 }
314
315 pub fn set_section(&mut self, name: &str, content: &str) {
317 self.sections.insert(name.to_string(), content.to_string());
318 }
319
320 pub fn entries_by_type(&self, entry_type: EntryType) -> Vec<&ProgressEntry> {
322 self.entries
323 .iter()
324 .filter(|e| {
325 std::mem::discriminant(&e.entry_type) == std::mem::discriminant(&entry_type)
326 })
327 .collect()
328 }
329
330 pub fn to_json(&self) -> Result<String> {
332 serde_json::to_string_pretty(self).context("Failed to serialize progress journal")
333 }
334
335 pub fn to_markdown(&self) -> String {
337 let mut md = String::new();
338
339 md.push_str(&format!("# Progress Journal: {}\n\n", self.workflow_name));
341 md.push_str(&format!("**Run ID:** {}\n\n", self.run_id));
342 md.push_str(&format!(
343 "**Started:** {}\n\n",
344 self.start_time.format("%Y-%m-%d %H:%M:%S UTC")
345 ));
346 md.push_str(&format!(
347 "**Last Update:** {}\n\n",
348 self.last_update.format("%Y-%m-%d %H:%M:%S UTC")
349 ));
350
351 md.push_str("## Task\n\n");
353 md.push_str(&self.task);
354 md.push_str("\n\n");
355
356 if let Some(repo) = &self.repo {
358 md.push_str("## Repository\n\n");
359 md.push_str(&format!("- **Path:** {}\n", repo));
360 if let Some(branch) = &self.branch {
361 md.push_str(&format!("- **Branch:** {}\n", branch));
362 }
363 md.push('\n');
364 }
365
366 if !self.patterns.is_empty() {
368 md.push_str("## Codebase Patterns\n\n");
369 for pattern in &self.patterns {
370 md.push_str(&format!("### {}\n\n", pattern.category));
371 md.push_str(&format!("{}\n\n", pattern.description));
372 if let Some(example) = &pattern.example {
373 md.push_str(&format!("**Example:** `{}`\n\n", example));
374 }
375 md.push_str(&format!(
376 "**Reusable:** {}\n\n",
377 if pattern.reusable { "Yes" } else { "No" }
378 ));
379 }
380 }
381
382 if !self.test_snapshots.is_empty() {
384 md.push_str("## Test Results\n\n");
385 for snapshot in &self.test_snapshots {
386 let status = if snapshot.passed {
387 "✅ PASS"
388 } else {
389 "❌ FAIL"
390 };
391 md.push_str(&format!("- **{}** ({}s)\n", status, snapshot.duration_secs));
392 md.push_str(&format!(" - Command: `{}`\n", snapshot.command));
393 }
394 md.push('\n');
395 }
396
397 if !self.build_snapshots.is_empty() {
399 md.push_str("## Build Results\n\n");
400 for snapshot in &self.build_snapshots {
401 let status = if snapshot.succeeded {
402 "✅ SUCCESS"
403 } else {
404 "❌ FAILED"
405 };
406 md.push_str(&format!("- **{}** ({}s)\n", status, snapshot.duration_secs));
407 md.push_str(&format!(" - Command: `{}`\n", snapshot.command));
408 }
409 md.push('\n');
410 }
411
412 if !self.decisions.is_empty() {
414 md.push_str("## Decisions\n\n");
415 for decision in &self.decisions {
416 md.push_str(&format!("### {}\n\n", decision.title));
417 md.push_str(&format!("**Context:** {}\n\n", decision.context));
418 md.push_str(&format!("**Decision:** {}\n\n", decision.decision));
419 if !decision.consequences.is_empty() {
420 md.push_str("**Consequences:**\n");
421 for consequence in &decision.consequences {
422 md.push_str(&format!("- {}\n", consequence));
423 }
424 md.push('\n');
425 }
426 md.push_str(&format!(
427 "**Reversible:** {}\n\n",
428 if decision.reversible { "Yes" } else { "No" }
429 ));
430 }
431 }
432
433 if !self.risks.is_empty() {
435 md.push_str("## Risks\n\n");
436 for risk in &self.risks {
437 let level_icon = match risk.level {
438 RiskLevel::Low => "🟢",
439 RiskLevel::Medium => "🟡",
440 RiskLevel::High => "🔴",
441 RiskLevel::Critical => "⚠️",
442 };
443 let status = if risk.resolved {
444 "✅ Resolved"
445 } else {
446 "⏳ Open"
447 };
448
449 md.push_str(&format!("### {} {}\n\n", level_icon, status));
450 md.push_str(&format!("{}\n\n", risk.description));
451
452 if let Some(mitigation) = &risk.mitigation {
453 md.push_str(&format!("**Mitigation:** {}\n\n", mitigation));
454 }
455
456 if let Some(notes) = &risk.resolution_notes {
457 md.push_str(&format!("**Resolution:** {}\n\n", notes));
458 }
459 }
460 }
461
462 if !self.entries.is_empty() {
464 md.push_str("## Timeline\n\n");
465 for entry in &self.entries {
466 let entry_type_str = format!("{:?}", entry.entry_type);
467 md.push_str(&format!(
468 "**{}** [{}] *{}*\n\n",
469 entry.timestamp.format("%H:%M:%S"),
470 entry_type_str,
471 entry.step_id
472 ));
473 md.push_str(&format!("**{}**\n\n", entry.title));
474 md.push_str(&format!("{}\n\n", entry.details));
475 }
476 }
477
478 for (name, content) in &self.sections {
480 md.push_str(&format!("## {}\n\n", name));
481 md.push_str(content);
482 md.push_str("\n\n");
483 }
484
485 md
486 }
487
488 pub async fn save_to_file(&self, path: &Path) -> Result<()> {
490 let markdown = self.to_markdown();
491 tokio::fs::write(path, markdown)
492 .await
493 .context("Failed to write progress journal")?;
494 Ok(())
495 }
496
497 pub async fn load_from_file(path: &Path) -> Result<Self> {
499 let content = tokio::fs::read_to_string(path)
500 .await
501 .context("Failed to read progress journal file")?;
502
503 serde_json::from_str(&content).context("Failed to parse progress journal JSON")
504 }
505}
506
507pub struct ProgressJournalWriter {
509 journal: ProgressJournal,
510}
511
512impl ProgressJournalWriter {
513 pub fn new(run_id: String, workflow_name: String, task: String) -> Self {
515 Self {
516 journal: ProgressJournal::new(run_id, workflow_name, task),
517 }
518 }
519
520 pub fn journal_mut(&mut self) -> &mut ProgressJournal {
522 &mut self.journal
523 }
524
525 pub fn log_story_start(&mut self, step_id: &str, story_id: &str, story_title: &str) {
527 self.journal.add_entry(
528 EntryType::StoryStart,
529 step_id,
530 &format!("Starting story: {}", story_title),
531 &format!("Story ID: {}", story_id),
532 );
533 }
534
535 pub fn log_story_complete(
537 &mut self,
538 step_id: &str,
539 story_id: &str,
540 story_title: &str,
541 changes: &str,
542 ) {
543 self.journal.add_entry(
544 EntryType::StoryComplete,
545 step_id,
546 &format!("Completed story: {}", story_title),
547 &format!("Story ID: {}\n\nChanges:\n{}", story_id, changes),
548 );
549 }
550
551 pub fn log_pattern(
553 &mut self,
554 step_id: &str,
555 category: &str,
556 description: &str,
557 example: Option<&str>,
558 ) {
559 self.journal
560 .add_pattern(category, description, example, true);
561 self.journal.add_entry(
562 EntryType::PatternDiscovered,
563 step_id,
564 &format!("Discovered pattern: {}", category),
565 description,
566 );
567 }
568
569 pub fn log_test_results(
571 &mut self,
572 step_id: &str,
573 command: &str,
574 output: &str,
575 passed: bool,
576 duration_secs: f64,
577 ) {
578 self.journal
579 .add_test_snapshot(command, output, passed, duration_secs);
580 self.journal.add_entry(
581 EntryType::TestResults,
582 step_id,
583 if passed {
584 "Tests passed"
585 } else {
586 "Tests failed"
587 },
588 &format!("Command: {}\n\nDuration: {:.2}s", command, duration_secs),
589 );
590 }
591
592 pub fn log_build_results(
594 &mut self,
595 step_id: &str,
596 command: &str,
597 output: &str,
598 succeeded: bool,
599 duration_secs: f64,
600 ) {
601 self.journal
602 .add_build_snapshot(command, output, succeeded, duration_secs);
603 self.journal.add_entry(
604 EntryType::BuildResults,
605 step_id,
606 if succeeded {
607 "Build succeeded"
608 } else {
609 "Build failed"
610 },
611 &format!("Command: {}\n\nDuration: {:.2}s", command, duration_secs),
612 );
613 }
614
615 pub fn log_decision(&mut self, step_id: &str, title: &str, context: &str, decision: &str) {
617 self.journal
618 .add_decision(title, context, decision, vec![], false);
619 self.journal.add_entry(
620 EntryType::Decision,
621 step_id,
622 &format!("Decision: {}", title),
623 decision,
624 );
625 }
626
627 pub fn log_risk(&mut self, step_id: &str, description: &str, level: RiskLevel) {
629 self.journal.add_risk(description, level, None);
630 self.journal.add_entry(
631 EntryType::Risk,
632 step_id,
633 &format!("Risk identified: {:?}", level),
634 description,
635 );
636 }
637
638 pub fn into_journal(self) -> ProgressJournal {
640 self.journal
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647
648 #[test]
649 fn test_progress_journal() {
650 let mut journal = ProgressJournal::new(
651 "run-123".to_string(),
652 "feature-dev".to_string(),
653 "Implement new auth system".to_string(),
654 );
655
656 journal.repo = Some("/path/to/repo".to_string());
657 journal.branch = Some("feature/auth".to_string());
658
659 journal.add_pattern(
660 "Error Handling",
661 "Use Result<T, E> for all fallible operations",
662 Some("src/error.rs:42"),
663 true,
664 );
665
666 journal.add_decision(
667 "Auth Library",
668 "Need to choose authentication library",
669 "Use JWT with jsonwebtoken crate",
670 vec!["Simpler than OAuth2 for our use case".to_string()],
671 true,
672 );
673
674 journal.add_risk(
675 "Token expiration edge cases",
676 RiskLevel::Medium,
677 Some("Add comprehensive tests"),
678 );
679
680 assert_eq!(journal.patterns.len(), 1);
681 assert_eq!(journal.decisions.len(), 1);
682 assert_eq!(journal.risks.len(), 1);
683 }
684
685 #[test]
686 fn test_to_markdown() {
687 let mut journal = ProgressJournal::new(
688 "run-123".to_string(),
689 "feature-dev".to_string(),
690 "Test task".to_string(),
691 );
692
693 journal.add_pattern("Test Pattern", "A test pattern", None, true);
694
695 let markdown = journal.to_markdown();
696 assert!(markdown.contains("# Progress Journal: feature-dev"));
697 assert!(markdown.contains("Test task"));
698 assert!(markdown.contains("Test Pattern"));
699 }
700}