use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgressEntry {
pub timestamp: DateTime<Utc>,
pub entry_type: EntryType,
pub step_id: String,
pub title: String,
pub details: String,
#[serde(default)]
pub metadata: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EntryType {
StoryStart,
StoryComplete,
PatternDiscovered,
TestResults,
BuildResults,
Decision,
Risk,
Milestone,
Error,
Info,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodebasePattern {
pub category: String,
pub description: String,
pub example: Option<String>,
pub reusable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestSnapshot {
pub command: String,
pub output: String,
pub passed: bool,
#[serde(default)]
pub failure_count: Option<usize>,
pub duration_secs: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuildSnapshot {
pub command: String,
pub output: String,
pub succeeded: bool,
#[serde(default)]
pub errors: Vec<String>,
#[serde(default)]
pub warnings: Vec<String>,
pub duration_secs: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Decision {
pub title: String,
pub context: String,
pub decision: String,
pub consequences: Vec<String>,
#[serde(default)]
pub alternatives: Vec<String>,
pub reversible: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Risk {
pub description: String,
pub level: RiskLevel,
pub mitigation: Option<String>,
#[serde(default)]
pub resolved: bool,
#[serde(default)]
pub resolution_notes: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RiskLevel {
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgressJournal {
pub run_id: String,
pub workflow_name: String,
pub start_time: DateTime<Utc>,
pub last_update: DateTime<Utc>,
pub task: String,
pub repo: Option<String>,
pub branch: Option<String>,
pub entries: Vec<ProgressEntry>,
#[serde(default)]
pub patterns: Vec<CodebasePattern>,
#[serde(default)]
pub decisions: Vec<Decision>,
#[serde(default)]
pub risks: Vec<Risk>,
#[serde(default)]
pub test_snapshots: Vec<TestSnapshot>,
#[serde(default)]
pub build_snapshots: Vec<BuildSnapshot>,
#[serde(default)]
pub sections: HashMap<String, String>,
}
impl ProgressJournal {
pub fn new(run_id: String, workflow_name: String, task: String) -> Self {
let now = Utc::now();
Self {
run_id,
workflow_name,
start_time: now,
last_update: now,
task,
repo: None,
branch: None,
entries: vec![],
patterns: vec![],
decisions: vec![],
risks: vec![],
test_snapshots: vec![],
build_snapshots: vec![],
sections: HashMap::new(),
}
}
pub fn add_entry(&mut self, entry_type: EntryType, step_id: &str, title: &str, details: &str) {
self.entries.push(ProgressEntry {
timestamp: Utc::now(),
entry_type,
step_id: step_id.to_string(),
title: title.to_string(),
details: details.to_string(),
metadata: HashMap::new(),
});
self.last_update = Utc::now();
}
pub fn add_pattern(
&mut self,
category: &str,
description: &str,
example: Option<&str>,
reusable: bool,
) {
self.patterns.push(CodebasePattern {
category: category.to_string(),
description: description.to_string(),
example: example.map(|s| s.to_string()),
reusable,
});
}
pub fn add_decision(
&mut self,
title: &str,
context: &str,
decision: &str,
consequences: Vec<String>,
reversible: bool,
) {
self.decisions.push(Decision {
title: title.to_string(),
context: context.to_string(),
decision: decision.to_string(),
consequences,
alternatives: vec![],
reversible,
});
}
pub fn add_risk(&mut self, description: &str, level: RiskLevel, mitigation: Option<&str>) {
self.risks.push(Risk {
description: description.to_string(),
level,
mitigation: mitigation.map(|s| s.to_string()),
resolved: false,
resolution_notes: None,
});
}
pub fn resolve_risk(&mut self, description: &str, notes: &str) {
if let Some(risk) = self.risks.iter_mut().find(|r| r.description == description) {
risk.resolved = true;
risk.resolution_notes = Some(notes.to_string());
}
}
pub fn add_test_snapshot(
&mut self,
command: &str,
output: &str,
passed: bool,
duration_secs: f64,
) {
self.test_snapshots.push(TestSnapshot {
command: command.to_string(),
output: output.to_string(),
passed,
failure_count: None,
duration_secs,
});
}
pub fn add_build_snapshot(
&mut self,
command: &str,
output: &str,
succeeded: bool,
duration_secs: f64,
) {
self.build_snapshots.push(BuildSnapshot {
command: command.to_string(),
output: output.to_string(),
succeeded,
errors: vec![],
warnings: vec![],
duration_secs,
});
}
pub fn set_section(&mut self, name: &str, content: &str) {
self.sections.insert(name.to_string(), content.to_string());
}
pub fn entries_by_type(&self, entry_type: EntryType) -> Vec<&ProgressEntry> {
self.entries
.iter()
.filter(|e| {
std::mem::discriminant(&e.entry_type) == std::mem::discriminant(&entry_type)
})
.collect()
}
pub fn to_json(&self) -> Result<String> {
serde_json::to_string_pretty(self).context("Failed to serialize progress journal")
}
pub fn to_markdown(&self) -> String {
let mut md = String::new();
md.push_str(&format!("# Progress Journal: {}\n\n", self.workflow_name));
md.push_str(&format!("**Run ID:** {}\n\n", self.run_id));
md.push_str(&format!(
"**Started:** {}\n\n",
self.start_time.format("%Y-%m-%d %H:%M:%S UTC")
));
md.push_str(&format!(
"**Last Update:** {}\n\n",
self.last_update.format("%Y-%m-%d %H:%M:%S UTC")
));
md.push_str("## Task\n\n");
md.push_str(&self.task);
md.push_str("\n\n");
if let Some(repo) = &self.repo {
md.push_str("## Repository\n\n");
md.push_str(&format!("- **Path:** {}\n", repo));
if let Some(branch) = &self.branch {
md.push_str(&format!("- **Branch:** {}\n", branch));
}
md.push('\n');
}
if !self.patterns.is_empty() {
md.push_str("## Codebase Patterns\n\n");
for pattern in &self.patterns {
md.push_str(&format!("### {}\n\n", pattern.category));
md.push_str(&format!("{}\n\n", pattern.description));
if let Some(example) = &pattern.example {
md.push_str(&format!("**Example:** `{}`\n\n", example));
}
md.push_str(&format!(
"**Reusable:** {}\n\n",
if pattern.reusable { "Yes" } else { "No" }
));
}
}
if !self.test_snapshots.is_empty() {
md.push_str("## Test Results\n\n");
for snapshot in &self.test_snapshots {
let status = if snapshot.passed {
"✅ PASS"
} else {
"❌ FAIL"
};
md.push_str(&format!("- **{}** ({}s)\n", status, snapshot.duration_secs));
md.push_str(&format!(" - Command: `{}`\n", snapshot.command));
}
md.push('\n');
}
if !self.build_snapshots.is_empty() {
md.push_str("## Build Results\n\n");
for snapshot in &self.build_snapshots {
let status = if snapshot.succeeded {
"✅ SUCCESS"
} else {
"❌ FAILED"
};
md.push_str(&format!("- **{}** ({}s)\n", status, snapshot.duration_secs));
md.push_str(&format!(" - Command: `{}`\n", snapshot.command));
}
md.push('\n');
}
if !self.decisions.is_empty() {
md.push_str("## Decisions\n\n");
for decision in &self.decisions {
md.push_str(&format!("### {}\n\n", decision.title));
md.push_str(&format!("**Context:** {}\n\n", decision.context));
md.push_str(&format!("**Decision:** {}\n\n", decision.decision));
if !decision.consequences.is_empty() {
md.push_str("**Consequences:**\n");
for consequence in &decision.consequences {
md.push_str(&format!("- {}\n", consequence));
}
md.push('\n');
}
md.push_str(&format!(
"**Reversible:** {}\n\n",
if decision.reversible { "Yes" } else { "No" }
));
}
}
if !self.risks.is_empty() {
md.push_str("## Risks\n\n");
for risk in &self.risks {
let level_icon = match risk.level {
RiskLevel::Low => "🟢",
RiskLevel::Medium => "🟡",
RiskLevel::High => "🔴",
RiskLevel::Critical => "⚠️",
};
let status = if risk.resolved {
"✅ Resolved"
} else {
"⏳ Open"
};
md.push_str(&format!("### {} {}\n\n", level_icon, status));
md.push_str(&format!("{}\n\n", risk.description));
if let Some(mitigation) = &risk.mitigation {
md.push_str(&format!("**Mitigation:** {}\n\n", mitigation));
}
if let Some(notes) = &risk.resolution_notes {
md.push_str(&format!("**Resolution:** {}\n\n", notes));
}
}
}
if !self.entries.is_empty() {
md.push_str("## Timeline\n\n");
for entry in &self.entries {
let entry_type_str = format!("{:?}", entry.entry_type);
md.push_str(&format!(
"**{}** [{}] *{}*\n\n",
entry.timestamp.format("%H:%M:%S"),
entry_type_str,
entry.step_id
));
md.push_str(&format!("**{}**\n\n", entry.title));
md.push_str(&format!("{}\n\n", entry.details));
}
}
for (name, content) in &self.sections {
md.push_str(&format!("## {}\n\n", name));
md.push_str(content);
md.push_str("\n\n");
}
md
}
pub async fn save_to_file(&self, path: &Path) -> Result<()> {
let markdown = self.to_markdown();
tokio::fs::write(path, markdown)
.await
.context("Failed to write progress journal")?;
Ok(())
}
pub async fn load_from_file(path: &Path) -> Result<Self> {
let content = tokio::fs::read_to_string(path)
.await
.context("Failed to read progress journal file")?;
serde_json::from_str(&content).context("Failed to parse progress journal JSON")
}
}
pub struct ProgressJournalWriter {
journal: ProgressJournal,
}
impl ProgressJournalWriter {
pub fn new(run_id: String, workflow_name: String, task: String) -> Self {
Self {
journal: ProgressJournal::new(run_id, workflow_name, task),
}
}
pub fn journal_mut(&mut self) -> &mut ProgressJournal {
&mut self.journal
}
pub fn log_story_start(&mut self, step_id: &str, story_id: &str, story_title: &str) {
self.journal.add_entry(
EntryType::StoryStart,
step_id,
&format!("Starting story: {}", story_title),
&format!("Story ID: {}", story_id),
);
}
pub fn log_story_complete(
&mut self,
step_id: &str,
story_id: &str,
story_title: &str,
changes: &str,
) {
self.journal.add_entry(
EntryType::StoryComplete,
step_id,
&format!("Completed story: {}", story_title),
&format!("Story ID: {}\n\nChanges:\n{}", story_id, changes),
);
}
pub fn log_pattern(
&mut self,
step_id: &str,
category: &str,
description: &str,
example: Option<&str>,
) {
self.journal
.add_pattern(category, description, example, true);
self.journal.add_entry(
EntryType::PatternDiscovered,
step_id,
&format!("Discovered pattern: {}", category),
description,
);
}
pub fn log_test_results(
&mut self,
step_id: &str,
command: &str,
output: &str,
passed: bool,
duration_secs: f64,
) {
self.journal
.add_test_snapshot(command, output, passed, duration_secs);
self.journal.add_entry(
EntryType::TestResults,
step_id,
if passed {
"Tests passed"
} else {
"Tests failed"
},
&format!("Command: {}\n\nDuration: {:.2}s", command, duration_secs),
);
}
pub fn log_build_results(
&mut self,
step_id: &str,
command: &str,
output: &str,
succeeded: bool,
duration_secs: f64,
) {
self.journal
.add_build_snapshot(command, output, succeeded, duration_secs);
self.journal.add_entry(
EntryType::BuildResults,
step_id,
if succeeded {
"Build succeeded"
} else {
"Build failed"
},
&format!("Command: {}\n\nDuration: {:.2}s", command, duration_secs),
);
}
pub fn log_decision(&mut self, step_id: &str, title: &str, context: &str, decision: &str) {
self.journal
.add_decision(title, context, decision, vec![], false);
self.journal.add_entry(
EntryType::Decision,
step_id,
&format!("Decision: {}", title),
decision,
);
}
pub fn log_risk(&mut self, step_id: &str, description: &str, level: RiskLevel) {
self.journal.add_risk(description, level, None);
self.journal.add_entry(
EntryType::Risk,
step_id,
&format!("Risk identified: {:?}", level),
description,
);
}
pub fn into_journal(self) -> ProgressJournal {
self.journal
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_progress_journal() {
let mut journal = ProgressJournal::new(
"run-123".to_string(),
"feature-dev".to_string(),
"Implement new auth system".to_string(),
);
journal.repo = Some("/path/to/repo".to_string());
journal.branch = Some("feature/auth".to_string());
journal.add_pattern(
"Error Handling",
"Use Result<T, E> for all fallible operations",
Some("src/error.rs:42"),
true,
);
journal.add_decision(
"Auth Library",
"Need to choose authentication library",
"Use JWT with jsonwebtoken crate",
vec!["Simpler than OAuth2 for our use case".to_string()],
true,
);
journal.add_risk(
"Token expiration edge cases",
RiskLevel::Medium,
Some("Add comprehensive tests"),
);
assert_eq!(journal.patterns.len(), 1);
assert_eq!(journal.decisions.len(), 1);
assert_eq!(journal.risks.len(), 1);
}
#[test]
fn test_to_markdown() {
let mut journal = ProgressJournal::new(
"run-123".to_string(),
"feature-dev".to_string(),
"Test task".to_string(),
);
journal.add_pattern("Test Pattern", "A test pattern", None, true);
let markdown = journal.to_markdown();
assert!(markdown.contains("# Progress Journal: feature-dev"));
assert!(markdown.contains("Test task"));
assert!(markdown.contains("Test Pattern"));
}
}