//! Structured progress logging for task execution.
//!
//! This module provides an append-only progress log file system that persists
//! learnings, patterns, and context across task iterations. The log is stored
//! in the task's worktree directory as `progress.log`.
//!
//! Format:
//! ```markdown
//! # progress.log
//! # Auto-generated by Makima - DO NOT EDIT MANUALLY
//!
//! ## [2024-01-15T10:30:00Z] - Task [fc09b908-...]: Implement user authentication
//! - Status: done
//! - Files changed: src/auth.rs, src/lib.rs
//! - **Learnings:**
//! - Pattern: Use bcrypt for password hashing
//! - Gotcha: Need to handle expired tokens gracefully
//! ---
//! ```
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs::{self, OpenOptions};
use std::io::{self, BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use uuid::Uuid;
/// Status of a completed task entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProgressEntryStatus {
/// Task completed successfully.
Done,
/// Task failed.
Failed,
}
impl std::fmt::Display for ProgressEntryStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProgressEntryStatus::Done => write!(f, "done"),
ProgressEntryStatus::Failed => write!(f, "failed"),
}
}
}
impl std::str::FromStr for ProgressEntryStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"done" | "completed" => Ok(ProgressEntryStatus::Done),
"failed" | "error" => Ok(ProgressEntryStatus::Failed),
_ => Err(format!("Unknown status: {}", s)),
}
}
}
/// A single progress log entry representing a completed task iteration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgressEntry {
/// Timestamp when the entry was created.
pub timestamp: DateTime<Utc>,
/// Task ID.
pub task_id: Uuid,
/// Human-readable task name.
pub task_name: String,
/// Completion status (done/failed).
pub status: ProgressEntryStatus,
/// List of files that were changed.
pub files_changed: Vec<String>,
/// Learnings discovered during task execution.
pub learnings: Vec<String>,
}
impl ProgressEntry {
/// Create a new progress entry.
pub fn new(
task_id: Uuid,
task_name: String,
status: ProgressEntryStatus,
files_changed: Vec<String>,
learnings: Vec<String>,
) -> Self {
Self {
timestamp: Utc::now(),
task_id,
task_name,
status,
files_changed,
learnings,
}
}
/// Format the entry as human-readable markdown.
pub fn to_markdown(&self) -> String {
let mut output = String::new();
// Header with timestamp, task ID, and name
output.push_str(&format!(
"## [{}] - Task [{}]: {}\n",
self.timestamp.format("%Y-%m-%dT%H:%M:%SZ"),
&self.task_id.to_string()[..8], // Short task ID
self.task_name
));
// Status
output.push_str(&format!("- Status: {}\n", self.status));
// Files changed
if self.files_changed.is_empty() {
output.push_str("- Files changed: (none)\n");
} else {
output.push_str(&format!(
"- Files changed: {}\n",
self.files_changed.join(", ")
));
}
// Learnings
output.push_str("- **Learnings:**\n");
if self.learnings.is_empty() {
output.push_str(" - (none recorded)\n");
} else {
for learning in &self.learnings {
output.push_str(&format!(" - {}\n", learning));
}
}
// Separator
output.push_str("---\n");
output
}
/// Parse a progress entry from markdown text.
///
/// Returns None if the text doesn't contain a valid progress entry.
pub fn from_markdown(text: &str) -> Option<Self> {
let lines: Vec<&str> = text.lines().collect();
// Parse header: ## [timestamp] - Task [id]: name
let header = lines.first()?;
if !header.starts_with("## [") {
return None;
}
// Extract timestamp
let timestamp_end = header.find(']')?;
let timestamp_str = &header[4..timestamp_end];
let timestamp = DateTime::parse_from_rfc3339(timestamp_str)
.ok()?
.with_timezone(&Utc);
// Extract task ID
let task_start = header.find("Task [")? + 6;
let task_end = header[task_start..].find(']')? + task_start;
let task_id_short = &header[task_start..task_end];
// Try to parse as UUID (we stored short version, need to handle that)
// For now, we'll generate a placeholder UUID if we can't parse the short version
let task_id = Uuid::parse_str(task_id_short)
.or_else(|_| {
// Try to parse with padding (short IDs are just first 8 chars)
Uuid::parse_str(&format!("{}-0000-0000-0000-000000000000", task_id_short))
})
.ok()?;
// Extract task name
let name_start = header.find("]: ")? + 3;
let task_name = header[name_start..].to_string();
// Parse remaining fields
let mut status = ProgressEntryStatus::Done;
let mut files_changed = Vec::new();
let mut learnings = Vec::new();
let mut in_learnings = false;
for line in &lines[1..] {
let line = line.trim();
if line.starts_with("- Status:") {
let status_str = line.trim_start_matches("- Status:").trim();
status = status_str.parse().unwrap_or(ProgressEntryStatus::Done);
in_learnings = false;
} else if line.starts_with("- Files changed:") {
let files_str = line.trim_start_matches("- Files changed:").trim();
if files_str != "(none)" {
files_changed = files_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
in_learnings = false;
} else if line.starts_with("- **Learnings:**") {
in_learnings = true;
} else if in_learnings && line.starts_with("- ") {
let learning = line.trim_start_matches("- ").trim();
if learning != "(none recorded)" {
learnings.push(learning.to_string());
}
} else if line == "---" {
break;
}
}
Some(ProgressEntry {
timestamp,
task_id,
task_name,
status,
files_changed,
learnings,
})
}
}
/// Progress log manager for a task's worktree.
pub struct ProgressLog {
/// Path to the progress.log file.
log_path: PathBuf,
}
/// Default file name for the progress log.
pub const PROGRESS_LOG_FILENAME: &str = "progress.log";
/// Default maximum number of entries to inject into prompts.
pub const DEFAULT_MAX_ENTRIES_INJECTED: usize = 20;
impl ProgressLog {
/// Create a new ProgressLog for a worktree directory.
pub fn new(worktree_path: &Path) -> Self {
Self {
log_path: Self::get_log_path(worktree_path),
}
}
/// Get the path to the progress log file for a worktree.
pub fn get_log_path(worktree_path: &Path) -> PathBuf {
worktree_path.join(PROGRESS_LOG_FILENAME)
}
/// Get the path to this progress log file.
pub fn path(&self) -> &Path {
&self.log_path
}
/// Check if the progress log file exists.
pub fn exists(&self) -> bool {
self.log_path.exists()
}
/// Append a progress entry to the log file.
///
/// Creates the file with a header if it doesn't exist.
pub fn append_entry(&self, entry: &ProgressEntry) -> io::Result<()> {
let file_exists = self.log_path.exists();
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.log_path)?;
// Write header if this is a new file
if !file_exists {
writeln!(file, "# progress.log")?;
writeln!(file, "# Auto-generated by Makima - DO NOT EDIT MANUALLY")?;
writeln!(file)?;
}
// Append the entry
write!(file, "{}", entry.to_markdown())?;
writeln!(file)?;
Ok(())
}
/// Read all entries from the progress log.
pub fn read_all_entries(&self) -> io::Result<Vec<ProgressEntry>> {
if !self.log_path.exists() {
return Ok(Vec::new());
}
let file = fs::File::open(&self.log_path)?;
let reader = BufReader::new(file);
let mut entries = Vec::new();
let mut current_entry = String::new();
for line in reader.lines() {
let line = line?;
// Start of a new entry
if line.starts_with("## [") {
// Process previous entry if any
if !current_entry.is_empty() {
if let Some(entry) = ProgressEntry::from_markdown(¤t_entry) {
entries.push(entry);
}
current_entry.clear();
}
}
// Skip header lines
if line.starts_with("# ") || line.is_empty() {
continue;
}
current_entry.push_str(&line);
current_entry.push('\n');
}
// Process last entry
if !current_entry.is_empty() {
if let Some(entry) = ProgressEntry::from_markdown(¤t_entry) {
entries.push(entry);
}
}
Ok(entries)
}
/// Read the most recent entries from the progress log.
///
/// Returns up to `max` entries, starting from the most recent.
pub fn read_recent_entries(&self, max: usize) -> io::Result<Vec<ProgressEntry>> {
let mut entries = self.read_all_entries()?;
// Return the last `max` entries (most recent)
if entries.len() > max {
entries = entries.split_off(entries.len() - max);
}
Ok(entries)
}
/// Format recent entries for injection into a prompt.
///
/// Returns a formatted string containing the recent progress entries
/// suitable for including in a Claude prompt.
pub fn format_for_prompt(&self, max_entries: usize) -> io::Result<String> {
let entries = self.read_recent_entries(max_entries)?;
if entries.is_empty() {
return Ok(String::new());
}
let mut output = String::new();
output.push_str("## Previous Task Progress\n\n");
output.push_str("The following entries show recent task completions and learnings:\n\n");
for entry in &entries {
output.push_str(&entry.to_markdown());
output.push('\n');
}
Ok(output)
}
/// Get the total number of entries in the log.
pub fn entry_count(&self) -> io::Result<usize> {
Ok(self.read_all_entries()?.len())
}
}
/// Helper function to create a progress entry and append it to a log.
///
/// This is a convenience function for the common case of appending a single entry.
pub fn append_progress_entry(
worktree_path: &Path,
task_id: Uuid,
task_name: String,
status: ProgressEntryStatus,
files_changed: Vec<String>,
learnings: Vec<String>,
) -> io::Result<()> {
let log = ProgressLog::new(worktree_path);
let entry = ProgressEntry::new(task_id, task_name, status, files_changed, learnings);
log.append_entry(&entry)
}
/// Get the list of files changed in a git worktree.
///
/// This runs `git diff --name-only HEAD~1` to get files changed in the last commit,
/// falling back to `git diff --name-only` for uncommitted changes if no commits exist.
pub async fn get_git_changed_files(worktree_path: &Path) -> Vec<String> {
// Try to get files from the last commit first
let output = tokio::process::Command::new("git")
.args(["diff", "--name-only", "HEAD~1"])
.current_dir(worktree_path)
.output()
.await;
if let Ok(output) = output {
if output.status.success() {
let files: Vec<String> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
if !files.is_empty() {
return files;
}
}
}
// Fall back to uncommitted changes
let output = tokio::process::Command::new("git")
.args(["diff", "--name-only", "HEAD"])
.current_dir(worktree_path)
.output()
.await;
if let Ok(output) = output {
if output.status.success() {
return String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
}
}
// Try to get all tracked files that have been modified
let output = tokio::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(worktree_path)
.output()
.await;
if let Ok(output) = output {
if output.status.success() {
return String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|s| !s.is_empty())
.filter_map(|line| {
// Format: "XY filename" where XY is the status
if line.len() > 3 {
Some(line[3..].to_string())
} else {
None
}
})
.collect();
}
}
Vec::new()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_progress_entry_to_markdown() {
let entry = ProgressEntry {
timestamp: DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
.unwrap()
.with_timezone(&Utc),
task_id: Uuid::parse_str("fc09b908-1234-5678-abcd-ef1234567890").unwrap(),
task_name: "Implement user authentication".to_string(),
status: ProgressEntryStatus::Done,
files_changed: vec!["src/auth.rs".to_string(), "src/lib.rs".to_string()],
learnings: vec![
"Pattern: Use bcrypt for password hashing".to_string(),
"Gotcha: Need to handle expired tokens gracefully".to_string(),
],
};
let markdown = entry.to_markdown();
assert!(markdown.contains("## [2024-01-15T10:30:00Z]"));
assert!(markdown.contains("Task [fc09b908]"));
assert!(markdown.contains("Implement user authentication"));
assert!(markdown.contains("Status: done"));
assert!(markdown.contains("src/auth.rs, src/lib.rs"));
assert!(markdown.contains("Use bcrypt for password hashing"));
assert!(markdown.contains("---"));
}
#[test]
fn test_progress_entry_roundtrip() {
let entry = ProgressEntry {
timestamp: DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
.unwrap()
.with_timezone(&Utc),
task_id: Uuid::parse_str("fc09b908-0000-0000-0000-000000000000").unwrap(),
task_name: "Test task".to_string(),
status: ProgressEntryStatus::Failed,
files_changed: vec!["test.rs".to_string()],
learnings: vec!["Learning 1".to_string()],
};
let markdown = entry.to_markdown();
let parsed = ProgressEntry::from_markdown(&markdown).unwrap();
assert_eq!(parsed.timestamp, entry.timestamp);
assert_eq!(parsed.task_name, entry.task_name);
assert_eq!(parsed.status, entry.status);
assert_eq!(parsed.files_changed, entry.files_changed);
assert_eq!(parsed.learnings, entry.learnings);
}
#[test]
fn test_progress_log_append_and_read() {
let dir = tempdir().unwrap();
let log = ProgressLog::new(dir.path());
// Initially no entries
assert!(!log.exists());
assert_eq!(log.read_all_entries().unwrap().len(), 0);
// Append first entry
let entry1 = ProgressEntry::new(
Uuid::new_v4(),
"Task 1".to_string(),
ProgressEntryStatus::Done,
vec!["file1.rs".to_string()],
vec!["Learning A".to_string()],
);
log.append_entry(&entry1).unwrap();
assert!(log.exists());
let entries = log.read_all_entries().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].task_name, "Task 1");
// Append second entry
let entry2 = ProgressEntry::new(
Uuid::new_v4(),
"Task 2".to_string(),
ProgressEntryStatus::Failed,
vec!["file2.rs".to_string()],
vec!["Learning B".to_string()],
);
log.append_entry(&entry2).unwrap();
let entries = log.read_all_entries().unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[1].task_name, "Task 2");
}
#[test]
fn test_progress_log_read_recent() {
let dir = tempdir().unwrap();
let log = ProgressLog::new(dir.path());
// Append 5 entries
for i in 1..=5 {
let entry = ProgressEntry::new(
Uuid::new_v4(),
format!("Task {}", i),
ProgressEntryStatus::Done,
vec![],
vec![],
);
log.append_entry(&entry).unwrap();
}
// Read only last 3
let recent = log.read_recent_entries(3).unwrap();
assert_eq!(recent.len(), 3);
assert_eq!(recent[0].task_name, "Task 3");
assert_eq!(recent[1].task_name, "Task 4");
assert_eq!(recent[2].task_name, "Task 5");
}
#[test]
fn test_progress_log_format_for_prompt() {
let dir = tempdir().unwrap();
let log = ProgressLog::new(dir.path());
let entry = ProgressEntry::new(
Uuid::new_v4(),
"Important task".to_string(),
ProgressEntryStatus::Done,
vec!["src/main.rs".to_string()],
vec!["Key insight".to_string()],
);
log.append_entry(&entry).unwrap();
let prompt = log.format_for_prompt(10).unwrap();
assert!(prompt.contains("## Previous Task Progress"));
assert!(prompt.contains("Important task"));
assert!(prompt.contains("Key insight"));
}
#[test]
fn test_progress_entry_status_display() {
assert_eq!(format!("{}", ProgressEntryStatus::Done), "done");
assert_eq!(format!("{}", ProgressEntryStatus::Failed), "failed");
}
#[test]
fn test_progress_entry_status_parse() {
assert_eq!(
"done".parse::<ProgressEntryStatus>().unwrap(),
ProgressEntryStatus::Done
);
assert_eq!(
"completed".parse::<ProgressEntryStatus>().unwrap(),
ProgressEntryStatus::Done
);
assert_eq!(
"failed".parse::<ProgressEntryStatus>().unwrap(),
ProgressEntryStatus::Failed
);
assert_eq!(
"error".parse::<ProgressEntryStatus>().unwrap(),
ProgressEntryStatus::Failed
);
assert!("unknown".parse::<ProgressEntryStatus>().is_err());
}
#[test]
fn test_empty_files_and_learnings() {
let entry = ProgressEntry::new(
Uuid::new_v4(),
"Empty task".to_string(),
ProgressEntryStatus::Done,
vec![],
vec![],
);
let markdown = entry.to_markdown();
assert!(markdown.contains("Files changed: (none)"));
assert!(markdown.contains("(none recorded)"));
}
#[test]
fn test_progress_log_file_header() {
let dir = tempdir().unwrap();
let log = ProgressLog::new(dir.path());
let entry = ProgressEntry::new(
Uuid::new_v4(),
"Test".to_string(),
ProgressEntryStatus::Done,
vec![],
vec![],
);
log.append_entry(&entry).unwrap();
let content = fs::read_to_string(log.path()).unwrap();
assert!(content.starts_with("# progress.log\n"));
assert!(content.contains("Auto-generated by Makima"));
}
#[test]
fn test_append_progress_entry_helper() {
let dir = tempdir().unwrap();
append_progress_entry(
dir.path(),
Uuid::new_v4(),
"Helper test".to_string(),
ProgressEntryStatus::Done,
vec!["file.rs".to_string()],
vec!["A learning".to_string()],
)
.unwrap();
let log = ProgressLog::new(dir.path());
let entries = log.read_all_entries().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].task_name, "Helper test");
}
}