use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use super::{WorktreeState, WorktreeStatus};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct EnhancedSessionInfo {
pub session_id: String,
pub status: WorktreeStatus,
pub workflow_path: Option<PathBuf>,
pub workflow_args: Vec<String>,
pub started_at: DateTime<Utc>,
pub last_activity: DateTime<Utc>,
pub current_step: usize,
pub total_steps: Option<usize>,
pub error_summary: Option<String>,
pub branch_name: String,
pub parent_branch: Option<String>,
pub worktree_path: PathBuf,
pub files_changed: u32,
pub commits: u32,
pub items_processed: Option<u32>,
pub total_items: Option<u32>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct WorktreeSummary {
pub total: usize,
pub in_progress: usize,
pub interrupted: usize,
pub failed: usize,
pub completed: usize,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DetailedWorktreeList {
pub sessions: Vec<EnhancedSessionInfo>,
pub summary: WorktreeSummary,
}
pub trait SessionDisplay {
fn format_default(&self) -> String;
fn format_verbose(&self) -> String;
fn format_json(&self) -> serde_json::Value;
}
impl SessionDisplay for EnhancedSessionInfo {
fn format_default(&self) -> String {
let workflow_name = self
.workflow_path
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let args = if self.workflow_args.is_empty() {
String::new()
} else {
format!(" ({})", self.workflow_args.join(" "))
};
let status_emoji = match self.status {
WorktreeStatus::InProgress => "🔄",
WorktreeStatus::Completed => "✅",
WorktreeStatus::Merged => "🔀",
WorktreeStatus::CleanedUp => "🧹",
WorktreeStatus::Failed => "❌",
WorktreeStatus::Abandoned => "⚠️",
WorktreeStatus::Interrupted => "⏸️",
};
let progress = if let Some(total) = self.total_steps {
format!("step {}/{}", self.current_step, total)
} else if let (Some(processed), Some(total)) = (self.items_processed, self.total_items) {
format!("processed {}/{} items", processed, total)
} else {
format!("step {}", self.current_step)
};
let time_info = format_time_relative(&self.started_at, &self.last_activity);
let mut output = format!(
"📂 {}{}\n └─ {} [{}{}]\n Status: {} {} ({}) • {}",
workflow_name,
args,
self.session_id,
self.branch_name,
self.parent_branch
.as_ref()
.map(|p| format!(" → {}", p))
.unwrap_or_default(),
status_emoji,
format_status(&self.status),
progress,
time_info
);
if let Some(error) = &self.error_summary {
output.push_str(&format!("\n Error: \"{}\"", error));
}
output
}
fn format_verbose(&self) -> String {
let mut output = self.format_default();
output.push_str(&format!(
"\n Files changed: {} • Commits: {}",
self.files_changed, self.commits
));
output.push_str(&format!(
"\n Worktree: {}",
self.worktree_path.display()
));
output
}
fn format_json(&self) -> serde_json::Value {
serde_json::to_value(self).unwrap_or_else(|_| serde_json::json!({}))
}
}
impl SessionDisplay for DetailedWorktreeList {
fn format_default(&self) -> String {
if self.sessions.is_empty() {
return "No active Prodigy worktrees found.".to_string();
}
let mut output = format!("Active Prodigy worktrees ({} total):\n", self.summary.total);
for session in &self.sessions {
output.push_str(&format!("\n{}\n", session.format_default()));
}
if self.summary.total > 0 {
output.push_str(&format!(
"\nSummary: {} in progress, {} interrupted, {} failed, {} completed",
self.summary.in_progress,
self.summary.interrupted,
self.summary.failed,
self.summary.completed
));
}
output
}
fn format_verbose(&self) -> String {
if self.sessions.is_empty() {
return "No active Prodigy worktrees found.".to_string();
}
let mut output = format!("Active Prodigy worktrees ({} total):\n", self.summary.total);
for session in &self.sessions {
output.push_str(&format!("\n{}\n", session.format_verbose()));
}
if self.summary.total > 0 {
output.push_str(&format!(
"\nSummary: {} in progress, {} interrupted, {} failed, {} completed",
self.summary.in_progress,
self.summary.interrupted,
self.summary.failed,
self.summary.completed
));
}
output
}
fn format_json(&self) -> serde_json::Value {
serde_json::to_value(self).unwrap_or_else(|_| serde_json::json!({}))
}
}
fn format_status(status: &WorktreeStatus) -> &str {
match status {
WorktreeStatus::InProgress => "InProgress",
WorktreeStatus::Completed => "Completed",
WorktreeStatus::Merged => "Merged",
WorktreeStatus::CleanedUp => "CleanedUp",
WorktreeStatus::Failed => "Failed",
WorktreeStatus::Abandoned => "Abandoned",
WorktreeStatus::Interrupted => "Interrupted",
}
}
fn format_time_relative(started: &DateTime<Utc>, last_activity: &DateTime<Utc>) -> String {
let now = Utc::now();
let started_ago = format_duration(now.signed_duration_since(*started));
let active_ago = format_duration(now.signed_duration_since(*last_activity));
format!("Started: {} • Last active: {}", started_ago, active_ago)
}
fn format_duration(duration: chrono::Duration) -> String {
let total_seconds = duration.num_seconds().abs();
if total_seconds < 60 {
format!("{}s ago", total_seconds)
} else if total_seconds < 3600 {
format!("{}m ago", total_seconds / 60)
} else if total_seconds < 86400 {
format!("{}h ago", total_seconds / 3600)
} else {
format!("{}d ago", total_seconds / 86400)
}
}
impl From<&WorktreeState> for EnhancedSessionInfo {
fn from(state: &WorktreeState) -> Self {
EnhancedSessionInfo {
session_id: state.session_id.clone(),
status: state.status.clone(),
workflow_path: None, workflow_args: vec![],
started_at: state.created_at,
last_activity: state.updated_at,
current_step: 0,
total_steps: None,
error_summary: state.error.clone(),
branch_name: state.branch.clone(),
parent_branch: None, worktree_path: PathBuf::new(), files_changed: state.stats.files_changed,
commits: state.stats.commits,
items_processed: None,
total_items: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn test_format_duration_seconds() {
let duration = chrono::Duration::seconds(45);
assert_eq!(format_duration(duration), "45s ago");
}
#[test]
fn test_format_duration_minutes() {
let duration = chrono::Duration::seconds(120);
assert_eq!(format_duration(duration), "2m ago");
let duration = chrono::Duration::seconds(3599);
assert_eq!(format_duration(duration), "59m ago");
}
#[test]
fn test_format_duration_hours() {
let duration = chrono::Duration::hours(2);
assert_eq!(format_duration(duration), "2h ago");
let duration = chrono::Duration::seconds(86399);
assert_eq!(format_duration(duration), "23h ago");
}
#[test]
fn test_format_duration_days() {
let duration = chrono::Duration::days(3);
assert_eq!(format_duration(duration), "3d ago");
let duration = chrono::Duration::days(365);
assert_eq!(format_duration(duration), "365d ago");
}
#[test]
fn test_format_status() {
assert_eq!(format_status(&WorktreeStatus::InProgress), "InProgress");
assert_eq!(format_status(&WorktreeStatus::Completed), "Completed");
assert_eq!(format_status(&WorktreeStatus::Failed), "Failed");
assert_eq!(format_status(&WorktreeStatus::Interrupted), "Interrupted");
assert_eq!(format_status(&WorktreeStatus::Merged), "Merged");
assert_eq!(format_status(&WorktreeStatus::CleanedUp), "CleanedUp");
assert_eq!(format_status(&WorktreeStatus::Abandoned), "Abandoned");
}
#[test]
fn test_format_time_relative() {
let now = Utc::now();
let started = now - chrono::Duration::hours(2);
let last_activity = now - chrono::Duration::minutes(30);
let result = format_time_relative(&started, &last_activity);
assert!(result.contains("Started: 2h ago"));
assert!(result.contains("Last active: 30m ago"));
}
#[test]
fn test_enhanced_session_info_format_default() {
let session = EnhancedSessionInfo {
session_id: "test-session-123".to_string(),
status: WorktreeStatus::InProgress,
workflow_path: Some(PathBuf::from("workflows/test.yaml")),
workflow_args: vec!["arg1".to_string(), "arg2".to_string()],
started_at: Utc::now() - chrono::Duration::hours(1),
last_activity: Utc::now() - chrono::Duration::minutes(5),
current_step: 3,
total_steps: Some(10),
error_summary: None,
branch_name: "feature-branch".to_string(),
parent_branch: Some("main".to_string()),
worktree_path: PathBuf::from("/tmp/worktree"),
files_changed: 5,
commits: 2,
items_processed: None,
total_items: None,
};
let output = session.format_default();
assert!(output.contains("test.yaml (arg1 arg2)"));
assert!(output.contains("test-session-123"));
assert!(output.contains("feature-branch → main"));
assert!(output.contains("🔄 InProgress"));
assert!(output.contains("step 3/10"));
}
#[test]
fn test_enhanced_session_info_with_mapreduce() {
let session = EnhancedSessionInfo {
session_id: "mapreduce-123".to_string(),
status: WorktreeStatus::InProgress,
workflow_path: Some(PathBuf::from("mapreduce.yaml")),
workflow_args: vec![],
started_at: Utc::now() - chrono::Duration::hours(1),
last_activity: Utc::now() - chrono::Duration::minutes(5),
current_step: 0,
total_steps: None,
error_summary: None,
branch_name: "mr-branch".to_string(),
parent_branch: None,
worktree_path: PathBuf::from("/tmp/worktree"),
files_changed: 0,
commits: 0,
items_processed: Some(25),
total_items: Some(100),
};
let output = session.format_default();
assert!(output.contains("processed 25/100 items"));
}
#[test]
fn test_enhanced_session_info_with_error() {
let session = EnhancedSessionInfo {
session_id: "failed-123".to_string(),
status: WorktreeStatus::Failed,
workflow_path: None,
workflow_args: vec![],
started_at: Utc::now() - chrono::Duration::hours(1),
last_activity: Utc::now() - chrono::Duration::minutes(5),
current_step: 5,
total_steps: None,
error_summary: Some("Command failed with exit code 1".to_string()),
branch_name: "failed-branch".to_string(),
parent_branch: None,
worktree_path: PathBuf::from("/tmp/worktree"),
files_changed: 3,
commits: 1,
items_processed: None,
total_items: None,
};
let output = session.format_default();
assert!(output.contains("❌ Failed"));
assert!(output.contains("Error: \"Command failed with exit code 1\""));
}
#[test]
fn test_enhanced_session_info_format_verbose() {
let session = EnhancedSessionInfo {
session_id: "verbose-test".to_string(),
status: WorktreeStatus::Completed,
workflow_path: Some(PathBuf::from("test.yaml")),
workflow_args: vec![],
started_at: Utc::now() - chrono::Duration::hours(2),
last_activity: Utc::now() - chrono::Duration::hours(1),
current_step: 10,
total_steps: Some(10),
error_summary: None,
branch_name: "done-branch".to_string(),
parent_branch: Some("main".to_string()),
worktree_path: PathBuf::from("/home/user/worktree"),
files_changed: 15,
commits: 5,
items_processed: None,
total_items: None,
};
let output = session.format_verbose();
assert!(output.contains("Files changed: 15"));
assert!(output.contains("Commits: 5"));
assert!(output.contains("Worktree: /home/user/worktree"));
}
#[test]
fn test_enhanced_session_info_format_json() {
let session = EnhancedSessionInfo {
session_id: "json-test".to_string(),
status: WorktreeStatus::InProgress,
workflow_path: Some(PathBuf::from("test.yaml")),
workflow_args: vec!["arg".to_string()],
started_at: Utc.timestamp_opt(1700000000, 0).unwrap(),
last_activity: Utc.timestamp_opt(1700003600, 0).unwrap(),
current_step: 2,
total_steps: Some(5),
error_summary: None,
branch_name: "test-branch".to_string(),
parent_branch: None,
worktree_path: PathBuf::from("/tmp/test"),
files_changed: 3,
commits: 1,
items_processed: None,
total_items: None,
};
let json = session.format_json();
assert_eq!(json["session_id"], "json-test");
assert_eq!(json["current_step"], 2);
assert_eq!(json["files_changed"], 3);
}
#[test]
fn test_detailed_worktree_list_empty() {
let list = DetailedWorktreeList {
sessions: vec![],
summary: WorktreeSummary::default(),
};
let output = list.format_default();
assert_eq!(output, "No active Prodigy worktrees found.");
let verbose = list.format_verbose();
assert_eq!(verbose, "No active Prodigy worktrees found.");
}
#[test]
fn test_detailed_worktree_list_with_sessions() {
let session1 = EnhancedSessionInfo {
session_id: "session-1".to_string(),
status: WorktreeStatus::InProgress,
workflow_path: Some(PathBuf::from("flow1.yaml")),
workflow_args: vec![],
started_at: Utc::now() - chrono::Duration::hours(1),
last_activity: Utc::now() - chrono::Duration::minutes(10),
current_step: 2,
total_steps: Some(5),
error_summary: None,
branch_name: "branch-1".to_string(),
parent_branch: Some("main".to_string()),
worktree_path: PathBuf::from("/tmp/wt1"),
files_changed: 3,
commits: 1,
items_processed: None,
total_items: None,
};
let session2 = EnhancedSessionInfo {
session_id: "session-2".to_string(),
status: WorktreeStatus::Completed,
workflow_path: Some(PathBuf::from("flow2.yaml")),
workflow_args: vec![],
started_at: Utc::now() - chrono::Duration::hours(3),
last_activity: Utc::now() - chrono::Duration::hours(2),
current_step: 10,
total_steps: Some(10),
error_summary: None,
branch_name: "branch-2".to_string(),
parent_branch: Some("main".to_string()),
worktree_path: PathBuf::from("/tmp/wt2"),
files_changed: 10,
commits: 5,
items_processed: None,
total_items: None,
};
let list = DetailedWorktreeList {
sessions: vec![session1, session2],
summary: WorktreeSummary {
total: 2,
in_progress: 1,
interrupted: 0,
failed: 0,
completed: 1,
},
};
let output = list.format_default();
assert!(output.contains("Active Prodigy worktrees (2 total)"));
assert!(output.contains("flow1.yaml"));
assert!(output.contains("flow2.yaml"));
assert!(output.contains("Summary: 1 in progress, 0 interrupted, 0 failed, 1 completed"));
}
#[test]
fn test_detailed_worktree_list_format_json() {
let list = DetailedWorktreeList {
sessions: vec![],
summary: WorktreeSummary {
total: 3,
in_progress: 1,
interrupted: 1,
failed: 0,
completed: 1,
},
};
let json = list.format_json();
assert_eq!(json["summary"]["total"], 3);
assert_eq!(json["summary"]["in_progress"], 1);
assert_eq!(json["summary"]["interrupted"], 1);
assert_eq!(json["summary"]["completed"], 1);
}
#[test]
fn test_worktree_state_conversion() {
struct TestState {
session_id: String,
status: WorktreeStatus,
branch: String,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
error: Option<String>,
stats: TestStats,
}
struct TestStats {
files_changed: u32,
commits: u32,
}
let state = TestState {
session_id: "convert-test".to_string(),
status: WorktreeStatus::InProgress,
branch: "test-branch".to_string(),
created_at: Utc::now() - chrono::Duration::hours(1),
updated_at: Utc::now() - chrono::Duration::minutes(5),
error: Some("Test error".to_string()),
stats: TestStats {
files_changed: 7,
commits: 3,
},
};
let session_info = EnhancedSessionInfo {
session_id: state.session_id.clone(),
status: state.status.clone(),
workflow_path: None,
workflow_args: vec![],
started_at: state.created_at,
last_activity: state.updated_at,
current_step: 0,
total_steps: None,
error_summary: state.error.clone(),
branch_name: state.branch.clone(),
parent_branch: None,
worktree_path: PathBuf::new(),
files_changed: state.stats.files_changed,
commits: state.stats.commits,
items_processed: None,
total_items: None,
};
assert_eq!(session_info.session_id, "convert-test");
assert_eq!(session_info.status, WorktreeStatus::InProgress);
assert_eq!(session_info.branch_name, "test-branch");
assert_eq!(session_info.files_changed, 7);
assert_eq!(session_info.commits, 3);
assert_eq!(session_info.error_summary, Some("Test error".to_string()));
}
#[test]
fn test_worktree_summary_default() {
let summary = WorktreeSummary::default();
assert_eq!(summary.total, 0);
assert_eq!(summary.in_progress, 0);
assert_eq!(summary.interrupted, 0);
assert_eq!(summary.failed, 0);
assert_eq!(summary.completed, 0);
}
}