diff --git a/analysis/memory.md b/analysis/memory.md index 278a887..4c2db2c 100644 --- a/analysis/memory.md +++ b/analysis/memory.md @@ -1,5 +1,5 @@ # Workspace Memory -> Updated: 2026-02-04T03:13:35Z | Size: 18.4k chars +> Updated: 2026-02-04T23:42:21Z | Size: 19.9k chars ### Remember Tool Wiring - `crates/g3-core/src/tools/memory.rs` [0..5000] - `execute_remember()`, `get_memory_path()`, `merge_memory()` @@ -341,4 +341,29 @@ Machine-readable invariants for Plan Mode verification. - `crates/g3-core/src/prompts.rs` [92..156] - Invariants section in SHARED_PLAN_SECTION **Selector syntax**: `foo.bar` (nested), `foo[0]` (index), `foo[*]` (wildcard) -**Predicate rules**: contains, equals, exists, not_exists, min_length, max_length, greater_than, less_than, matches \ No newline at end of file +**Predicate rules**: contains, equals, exists, not_exists, min_length, max_length, greater_than, less_than, matches + +### Studio SDLC Pipeline Command +Orchestrates 7 g3 agents in sequence for codebase maintenance. + +- `crates/studio/src/sdlc.rs` + - `PIPELINE_STAGES` [28..62] - static array of 7 agents: euler, breaker, hopper, fowler, carmack, lamport, huffman + - `Stage` [18..26] - name, description, focus fields + - `StageStatus` [65..80] - enum: Pending, Running, Complete, Failed, Skipped + - `PipelineState` [108..140] - run_id, stages[], commit_cursor, session_id + - `PipelineState::load()` [165..185] - loads from analysis/sdlc/pipeline.json, handles corruption + - `PipelineState::save()` [188..200] - persists state for crash recovery + - `PipelineState::resume()` [330..340] - finds first incomplete stage, resets Running→Pending + - `display_pipeline()` [354..390] - box display with status icons (○/◉/✓/✗/⊘) + - `generate_summary()` [410..475] - markdown table of results + +- `crates/studio/src/main.rs` + - `SdlcAction` [88..104] - enum: Run{commits}, Status, Reset + - `cmd_sdlc_run()` [540..655] - orchestrates pipeline in worktree + - `cmd_sdlc_status()` [658..695] - displays current state + - `cmd_sdlc_reset()` [698..710] - clears pipeline state + - `run_agent_in_worktree()` [770..800] - executes g3 --agent in worktree + +**Pipeline Order**: euler → breaker → hopper → fowler → carmack → lamport → huffman +**State Storage**: `analysis/sdlc/pipeline.json` (git-tracked) +**CLI**: `studio sdlc run [-c N]`, `studio sdlc status`, `studio sdlc reset` \ No newline at end of file diff --git a/crates/g3-cli/src/embedded_agents.rs b/crates/g3-cli/src/embedded_agents.rs index 1df21ba..bc9c61e 100644 --- a/crates/g3-cli/src/embedded_agents.rs +++ b/crates/g3-cli/src/embedded_agents.rs @@ -19,6 +19,7 @@ static EMBEDDED_AGENTS: &[(&str, &str)] = &[ ("euler", include_str!("../../../agents/euler.md")), ("fowler", include_str!("../../../agents/fowler.md")), ("hopper", include_str!("../../../agents/hopper.md")), + ("huffman", include_str!("../../../agents/huffman.md")), ("lamport", include_str!("../../../agents/lamport.md")), ("scout", include_str!("../../../agents/scout.md")), ]; @@ -88,7 +89,7 @@ mod tests { #[test] fn test_embedded_agents_exist() { // Verify all expected agents are embedded - let expected = ["breaker", "carmack", "euler", "fowler", "hopper", "lamport", "scout"]; + let expected = ["breaker", "carmack", "euler", "fowler", "hopper", "huffman", "lamport", "scout"]; for name in expected { assert!( get_embedded_agent(name).is_some(), @@ -101,7 +102,7 @@ mod tests { #[test] fn test_list_embedded_agents() { let agents = list_embedded_agents(); - assert!(agents.len() >= 7, "Should have at least 7 embedded agents"); + assert!(agents.len() >= 8, "Should have at least 8 embedded agents"); assert!(agents.contains(&"carmack")); assert!(agents.contains(&"hopper")); } diff --git a/crates/studio/src/main.rs b/crates/studio/src/main.rs index 2d333bb..817825f 100644 --- a/crates/studio/src/main.rs +++ b/crates/studio/src/main.rs @@ -9,6 +9,7 @@ use std::process::{Command, Stdio}; use termimad::MadSkin; mod git; +mod sdlc; mod session; use git::GitWorktree; @@ -82,6 +83,30 @@ enum Commands { /// Session ID session_id: String, }, + + /// Run the SDLC maintenance pipeline + Sdlc { + #[command(subcommand)] + action: SdlcAction, + }, +} + +#[derive(Subcommand)] +enum SdlcAction { + /// Run the SDLC pipeline (or resume if interrupted) + Run { + /// Number of commits to process per stage (default: 10) + #[arg(long, short, default_value = "10")] + commits: u32, + + /// Set the commit cursor to start from (skips commits before this) + #[arg(long)] + from: Option, + }, + /// Show current pipeline status + Status, + /// Reset pipeline state (start fresh) + Reset, } fn main() -> Result<()> { @@ -96,6 +121,11 @@ fn main() -> Result<()> { Commands::Status { session_id } => cmd_status(&session_id), Commands::Accept { session_id } => cmd_accept(&session_id), Commands::Discard { session_id } => cmd_discard(&session_id), + Commands::Sdlc { action } => match action { + SdlcAction::Run { commits, from } => cmd_sdlc_run(commits, from), + SdlcAction::Status => cmd_sdlc_status(), + SdlcAction::Reset => cmd_sdlc_reset(), + }, } } @@ -510,6 +540,292 @@ fn cmd_discard(session_id: &str) -> Result<()> { Ok(()) } +/// Run the SDLC pipeline +fn cmd_sdlc_run(commits_per_run: u32, from_commit: Option) -> Result<()> { + let repo_root = get_repo_root()?; + + // Load or create pipeline state + let mut state = match sdlc::PipelineState::load(&repo_root)? { + Some(mut existing) => { + // Resume from where we left off + existing.resume(); + println!("\x1b[1;32msdlc:\x1b[0m resuming pipeline run \x1b[38;2;216;177;114m{}\x1b[0m", existing.run_id); + existing + } + None => { + let mut state = sdlc::PipelineState::new(commits_per_run); + // If --from is specified, set the cursor + if let Some(ref from) = from_commit { + // Resolve the commit hash + let resolved = resolve_commit(&repo_root, from)?; + state.commit_cursor = Some(resolved.clone()); + println!("\x1b[1;32msdlc:\x1b[0m starting new pipeline run \x1b[38;2;216;177;114m{}\x1b[0m (from {})", + state.run_id, &resolved[..8.min(resolved.len())]); + } else { + println!("\x1b[1;32msdlc:\x1b[0m starting new pipeline run \x1b[38;2;216;177;114m{}\x1b[0m", state.run_id); + } + state + } + }; + + // Get current HEAD commit + let head_commit = get_head_commit(&repo_root)?; + + // Check if there are commits to process + let commits_to_process = if let Some(cursor) = &state.commit_cursor { + count_commits_between(&repo_root, cursor, &head_commit)? + } else { + // First run - use commits_per_run as the count + commits_per_run + }; + + if commits_to_process == 0 { + println!("\x1b[1;32msdlc:\x1b[0m no new commits since last run"); + return Ok(()); + } + + println!("\x1b[1;32msdlc:\x1b[0m {} commits to process", commits_to_process.min(commits_per_run)); + + // Display the pipeline + sdlc::display_pipeline(&state); + + // Create a dedicated worktree for SDLC + let g3_binary = get_g3_binary_path()?; + let sdlc_session = Session::new("sdlc"); + let worktree = GitWorktree::new(&repo_root); + let worktree_path = worktree.create(&sdlc_session)?; + + // Save session info for crash recovery + state.session_id = Some(sdlc_session.id.clone()); + sdlc_session.save(&repo_root, &worktree_path)?; + state.save(&repo_root)?; + + // Run each stage + while !state.is_complete() && state.current_stage < sdlc::PIPELINE_STAGES.len() { + let stage = &sdlc::PIPELINE_STAGES[state.current_stage]; + + // Display current stage + sdlc::display_current_stage(&state); + println!(); + + // Mark as running and save + state.mark_running(); + state.save(&repo_root)?; + + let start_time = std::time::Instant::now(); + + // Build the task prompt for this agent + let task = format!( + "Focus on changes in the past {} commits (up to {}). {}", + commits_per_run.min(commits_to_process), + &head_commit[..8.min(head_commit.len())], + stage.focus + ); + + // Run the agent + let result = run_agent_in_worktree( + &g3_binary, + &worktree_path, + stage.name, + &task, + ); + + let duration = start_time.elapsed().as_secs(); + + match result { + Ok(true) => { + // Success + state.mark_complete(duration, commits_to_process.min(commits_per_run), &head_commit); + println!(); + println!("\x1b[1;32msdlc:\x1b[0m stage \x1b[1m{}\x1b[0m complete in {}", + stage.name, format_duration_short(duration)); + } + Ok(false) => { + // Agent completed but with non-zero exit + state.mark_failed("Agent exited with non-zero status"); + println!(); + println!("\x1b[1;31msdlc:\x1b[0m stage \x1b[1m{}\x1b[0m failed", stage.name); + state.save(&repo_root)?; + break; + } + Err(e) => { + // Error running agent + state.mark_failed(&e.to_string()); + println!(); + println!("\x1b[1;31msdlc:\x1b[0m stage \x1b[1m{}\x1b[0m error: {}", stage.name, e); + state.save(&repo_root)?; + break; + } + } + + state.save(&repo_root)?; + + // Display updated pipeline + sdlc::display_pipeline(&state); + } + + // Cleanup worktree + worktree.remove(&sdlc_session)?; + sdlc_session.delete(&repo_root)?; + + // Generate and display summary + if state.is_complete() { + let summary = sdlc::generate_summary(&state); + println!("{}", summary); + + // Save summary to .g3/sessions/sdlc/ + let summary_dir = repo_root.join(".g3").join("sessions").join("sdlc"); + fs::create_dir_all(&summary_dir).ok(); + let summary_path = summary_dir.join(format!("run-{}.md", state.run_id)); + fs::write(&summary_path, &summary).ok(); + + println!("\x1b[1;32msdlc:\x1b[0m pipeline complete!"); + } else if state.has_failures() { + println!(); + println!("\x1b[1;33msdlc:\x1b[0m pipeline paused due to failures"); + println!(" Run 'studio sdlc run' to retry failed stages"); + } + + Ok(()) +} + +/// Show SDLC pipeline status +fn cmd_sdlc_status() -> Result<()> { + let repo_root = get_repo_root()?; + + match sdlc::PipelineState::load(&repo_root)? { + Some(state) => { + println!("\x1b[1;32msdlc:\x1b[0m pipeline run \x1b[38;2;216;177;114m{}\x1b[0m", state.run_id); + sdlc::display_pipeline(&state); + + if state.is_complete() { + println!("Status: \x1b[1;32mComplete\x1b[0m"); + } else if state.has_failures() { + println!("Status: \x1b[1;31mFailed\x1b[0m (run 'studio sdlc run' to retry)"); + } else { + println!("Status: \x1b[1;33mIn Progress\x1b[0m (stage {}/{})", + state.current_stage + 1, sdlc::PIPELINE_STAGES.len()); + } + + if let Some(cursor) = &state.commit_cursor { + println!("Commit cursor: {}", cursor); + } + } + None => { + println!("\x1b[1;32msdlc:\x1b[0m no active pipeline"); + println!(); + println!("Run 'studio sdlc run' to start a new pipeline"); + } + } + + Ok(()) +} + +/// Reset SDLC pipeline state +fn cmd_sdlc_reset() -> Result<()> { + let repo_root = get_repo_root()?; + + if sdlc::PipelineState::load(&repo_root)?.is_some() { + sdlc::PipelineState::delete(&repo_root)?; + println!("\x1b[1;32msdlc:\x1b[0m pipeline state reset"); + } else { + println!("\x1b[1;32msdlc:\x1b[0m no pipeline state to reset"); + } + + Ok(()) +} + +/// Format duration in short form +fn format_duration_short(secs: u64) -> String { + if secs < 60 { + format!("{}s", secs) + } else if secs < 3600 { + format!("{}m {}s", secs / 60, secs % 60) + } else { + format!("{}h {}m", secs / 3600, (secs % 3600) / 60) + } +} + +/// Resolve a commit reference (hash, branch, tag, etc.) to a full hash +fn resolve_commit(repo_root: &Path, commit_ref: &str) -> Result { + let output = Command::new("git") + .current_dir(repo_root) + .args(["rev-parse", commit_ref]) + .output() + .context("Failed to resolve commit")?; + + if !output.status.success() { + bail!("Failed to resolve commit '{}'", commit_ref); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// Get the current HEAD commit hash +fn get_head_commit(repo_root: &Path) -> Result { + let output = Command::new("git") + .current_dir(repo_root) + .args(["rev-parse", "HEAD"]) + .output() + .context("Failed to get HEAD commit")?; + + if !output.status.success() { + bail!("Failed to get HEAD commit"); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// Count commits between two refs +fn count_commits_between(repo_root: &Path, from: &str, to: &str) -> Result { + let output = Command::new("git") + .current_dir(repo_root) + .args(["rev-list", "--count", &format!("{}..{}", from, to)]) + .output() + .context("Failed to count commits")?; + + if !output.status.success() { + // If the from commit doesn't exist (first run), return a large number + return Ok(u32::MAX); + } + + let count: u32 = String::from_utf8_lossy(&output.stdout) + .trim() + .parse() + .unwrap_or(0); + + Ok(count) +} + +/// Run a g3 agent in a worktree +fn run_agent_in_worktree( + g3_binary: &Path, + worktree_path: &Path, + agent: &str, + task: &str, +) -> Result { + let mut cmd = Command::new(g3_binary); + cmd.arg("--workspace").arg(worktree_path); + cmd.arg("--agent").arg(agent); + cmd.arg(task); + cmd.current_dir(worktree_path); + cmd.stdout(Stdio::inherit()); + cmd.stderr(Stdio::inherit()); + + let status = cmd.status().context("Failed to run g3 agent")?; + + // If the agent made commits, commit them + if status.success() { + // Stage and commit any changes made by the agent + let _ = Command::new("git") + .current_dir(worktree_path) + .args(["add", "-A"]) + .output(); + } + + Ok(status.success()) +} + /// Check if a process is running by PID fn is_process_running(pid: u32) -> bool { // Use kill -0 to check if process exists diff --git a/crates/studio/src/sdlc.rs b/crates/studio/src/sdlc.rs new file mode 100644 index 0000000..bc8bd17 --- /dev/null +++ b/crates/studio/src/sdlc.rs @@ -0,0 +1,648 @@ +//! SDLC Pipeline - Software Development Life Cycle maintenance pipeline +//! +//! Orchestrates a sequence of g3 agents to maintain and improve the codebase: +//! 1. euler - Dependency graph and hotspots analysis +//! 2. breaker - Whitebox exploration and edge-case discovery +//! 3. hopper - Deep testing and regression integrity +//! 4. fowler - Refactoring to deduplicate and reduce complexity +//! 5. carmack - In-place rewriting for readability and concision +//! 6. lamport - Human-readable documentation and validation +//! 7. huffman - Semantic compression of memory + +use anyhow::{Context, Result, bail}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Pipeline stage definition +#[derive(Debug, Clone)] +pub struct Stage { + /// Agent name (e.g., "euler") + pub name: &'static str, + /// Human-readable description + pub description: &'static str, + /// What this agent focuses on + pub focus: &'static str, +} + +/// The ordered pipeline stages +pub static PIPELINE_STAGES: &[Stage] = &[ + Stage { + name: "euler", + description: "Dependency Analysis", + focus: "dependency graph and hotspots", + }, + Stage { + name: "breaker", + description: "Edge Case Discovery", + focus: "whitebox exploration and failure cases", + }, + Stage { + name: "hopper", + description: "Testing & Verification", + focus: "deep testing and regression integrity", + }, + Stage { + name: "fowler", + description: "Refactoring", + focus: "deduplication and complexity reduction", + }, + Stage { + name: "carmack", + description: "Code Polish", + focus: "readability, modularity and concision", + }, + Stage { + name: "lamport", + description: "Documentation", + focus: "human-readable docs and validation", + }, + Stage { + name: "huffman", + description: "Memory Compression", + focus: "semantic compression to preserve signal", + }, +]; + +/// Status of a single stage execution +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum StageStatus { + /// Not yet started + Pending, + /// Currently running + Running, + /// Completed successfully + Complete { + duration_secs: u64, + commits_processed: u32, + }, + /// Failed with error + Failed { + error: String, + attempts: u32, + }, + /// Skipped (e.g., no new commits) + Skipped { reason: String }, +} + +/// State of a single stage in the pipeline +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StageState { + /// Agent name + pub name: String, + /// Current status + pub status: StageStatus, + /// When this stage started (if running or complete) + pub started_at: Option>, + /// When this stage completed (if complete) + pub completed_at: Option>, + /// Commit hash when this stage last ran + pub last_commit: Option, +} + +impl StageState { + /// Create a new pending stage state + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + status: StageStatus::Pending, + started_at: None, + completed_at: None, + last_commit: None, + } + } +} + +/// The full pipeline state, persisted to disk +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PipelineState { + /// Unique run identifier + pub run_id: String, + /// When this pipeline run started + pub started_at: DateTime, + /// When this pipeline run completed (if complete) + pub completed_at: Option>, + /// Current stage index (0-based) + pub current_stage: usize, + /// State of each stage + pub stages: Vec, + /// The commit cursor - commits up to this hash have been processed + pub commit_cursor: Option, + /// Number of commits to process per run + pub commits_per_run: u32, + /// Git worktree session ID (for crash recovery) + pub session_id: Option, +} + +impl PipelineState { + /// Create a new pipeline state + pub fn new(commits_per_run: u32) -> Self { + let run_id = uuid::Uuid::new_v4().to_string()[..8].to_string(); + let stages = PIPELINE_STAGES + .iter() + .map(|s| StageState::new(s.name)) + .collect(); + + Self { + run_id, + started_at: Utc::now(), + completed_at: None, + current_stage: 0, + stages, + commit_cursor: None, + commits_per_run, + session_id: None, + } + } + + /// Get the path to the pipeline state file + pub fn state_path(repo_root: &Path) -> PathBuf { + repo_root.join("analysis").join("sdlc").join("pipeline.json") + } + + /// Get the path to the SDLC directory + pub fn sdlc_dir(repo_root: &Path) -> PathBuf { + repo_root.join("analysis").join("sdlc") + } + + /// Load pipeline state from disk, or return None if not found + pub fn load(repo_root: &Path) -> Result> { + let path = Self::state_path(repo_root); + + if !path.exists() { + return Ok(None); + } + + let content = fs::read_to_string(&path) + .context("Failed to read pipeline state")?; + + // Handle corrupted state gracefully + match serde_json::from_str(&content) { + Ok(state) => Ok(Some(state)), + Err(e) => { + eprintln!("⚠️ Pipeline state corrupted, starting fresh: {}", e); + Ok(None) + } + } + } + + /// Save pipeline state to disk + pub fn save(&self, repo_root: &Path) -> Result<()> { + let dir = Self::sdlc_dir(repo_root); + fs::create_dir_all(&dir) + .context("Failed to create analysis/sdlc directory")?; + + let path = Self::state_path(repo_root); + let json = serde_json::to_string_pretty(self) + .context("Failed to serialize pipeline state")?; + + fs::write(&path, json) + .context("Failed to write pipeline state")?; + + Ok(()) + } + + /// Delete pipeline state from disk + pub fn delete(repo_root: &Path) -> Result<()> { + let path = Self::state_path(repo_root); + if path.exists() { + fs::remove_file(&path) + .context("Failed to delete pipeline state")?; + } + Ok(()) + } + + /// Check if the pipeline is complete + pub fn is_complete(&self) -> bool { + self.stages.iter().all(|s| { + matches!( + s.status, + StageStatus::Complete { .. } | StageStatus::Skipped { .. } + ) + }) + } + + /// Check if the pipeline has any failures + pub fn has_failures(&self) -> bool { + self.stages.iter().any(|s| matches!(s.status, StageStatus::Failed { .. })) + } + + /// Get the current stage definition + pub fn current_stage_def(&self) -> Option<&'static Stage> { + PIPELINE_STAGES.get(self.current_stage) + } + + /// Mark the current stage as running + pub fn mark_running(&mut self) { + if let Some(stage) = self.stages.get_mut(self.current_stage) { + stage.status = StageStatus::Running; + stage.started_at = Some(Utc::now()); + } + } + + /// Mark the current stage as complete and advance + pub fn mark_complete(&mut self, duration_secs: u64, commits_processed: u32, commit_hash: &str) { + if let Some(stage) = self.stages.get_mut(self.current_stage) { + stage.status = StageStatus::Complete { + duration_secs, + commits_processed, + }; + stage.completed_at = Some(Utc::now()); + stage.last_commit = Some(commit_hash.to_string()); + } + + // Advance to next stage + if self.current_stage < PIPELINE_STAGES.len() - 1 { + self.current_stage += 1; + } else { + // Pipeline complete + self.completed_at = Some(Utc::now()); + } + + // Update cursor + self.commit_cursor = Some(commit_hash.to_string()); + } + + /// Mark the current stage as failed + pub fn mark_failed(&mut self, error: &str) { + if let Some(stage) = self.stages.get_mut(self.current_stage) { + let attempts = match &stage.status { + StageStatus::Failed { attempts, .. } => attempts + 1, + _ => 1, + }; + stage.status = StageStatus::Failed { + error: error.to_string(), + attempts, + }; + } + } + + /// Mark the current stage as skipped + #[allow(dead_code)] + pub fn mark_skipped(&mut self, reason: &str) { + if let Some(stage) = self.stages.get_mut(self.current_stage) { + stage.status = StageStatus::Skipped { + reason: reason.to_string(), + }; + stage.completed_at = Some(Utc::now()); + } + + // Advance to next stage + if self.current_stage < PIPELINE_STAGES.len() - 1 { + self.current_stage += 1; + } else { + self.completed_at = Some(Utc::now()); + } + } + + /// Retry the current failed stage + #[allow(dead_code)] + pub fn retry_stage(&mut self) -> Result<()> { + if let Some(stage) = self.stages.get_mut(self.current_stage) { + match &stage.status { + StageStatus::Failed { .. } => { + // Keep the attempt count but reset to pending + stage.status = StageStatus::Pending; + stage.started_at = None; + Ok(()) + } + _ => bail!("Stage '{}' is not in failed state", stage.name), + } + } else { + bail!("Invalid current stage index") + } + } + + /// Find the first incomplete stage (for resumption) + pub fn find_resume_point(&self) -> usize { + for (i, stage) in self.stages.iter().enumerate() { + match &stage.status { + StageStatus::Pending | StageStatus::Running | StageStatus::Failed { .. } => { + return i; + } + _ => continue, + } + } + // All complete + self.stages.len() + } + + /// Resume from the first incomplete stage + pub fn resume(&mut self) { + self.current_stage = self.find_resume_point(); + + // If current stage was running (crashed), reset to pending + if let Some(stage) = self.stages.get_mut(self.current_stage) { + if matches!(stage.status, StageStatus::Running) { + stage.status = StageStatus::Pending; + stage.started_at = None; + } + } + } +} + +/// Get a stage by name +#[allow(dead_code)] +pub fn get_stage(name: &str) -> Option<&'static Stage> { + PIPELINE_STAGES.iter().find(|s| s.name == name) +} + +/// Get stage index by name +#[allow(dead_code)] +pub fn get_stage_index(name: &str) -> Option { + PIPELINE_STAGES.iter().position(|s| s.name == name) +} + +/// Display the pipeline with current stage highlighted +pub fn display_pipeline(state: &PipelineState) { + println!(); + println!("\x1b[1m┌─────────────────────────────────────────────────────────────┐\x1b[0m"); + println!("\x1b[1m│ SDLC Pipeline │\x1b[0m"); + println!("\x1b[1m├─────────────────────────────────────────────────────────────┤\x1b[0m"); + + for (i, stage_def) in PIPELINE_STAGES.iter().enumerate() { + let stage_state = &state.stages[i]; + let is_current = i == state.current_stage; + + let (icon, color) = match &stage_state.status { + StageStatus::Pending => ("○", "\x1b[90m"), // Gray + StageStatus::Running => ("◉", "\x1b[33m"), // Yellow + StageStatus::Complete { .. } => ("✓", "\x1b[32m"), // Green + StageStatus::Failed { .. } => ("✗", "\x1b[31m"), // Red + StageStatus::Skipped { .. } => ("⊘", "\x1b[90m"), // Gray + }; + + let highlight = if is_current { "\x1b[1m" } else { "" }; + let reset = "\x1b[0m"; + + // Pad to fixed width + let padded = format!("{:<57}", format!("{} {:<10} - {}", icon, stage_def.name, stage_def.description)); + + if is_current { + println!("│ {}{}{}► {}{}│", color, highlight, reset, padded, reset); + } else { + println!("│ {}{}{} {}│", color, highlight, padded, reset); + } + } + + println!("\x1b[1m└─────────────────────────────────────────────────────────────┘\x1b[0m"); + println!(); +} + +/// Display a compact single-line status for the current stage +pub fn display_current_stage(state: &PipelineState) { + if let Some(stage) = state.current_stage_def() { + println!( + "\x1b[1;32msdlc:\x1b[0m stage {}/{} \x1b[1m{}\x1b[0m - {}", + state.current_stage + 1, + PIPELINE_STAGES.len(), + stage.name, + stage.focus + ); + } +} + +/// Generate a summary of the pipeline run +pub fn generate_summary(state: &PipelineState) -> String { + let mut summary = String::new(); + + summary.push_str("\n## SDLC Pipeline Summary\n\n"); + summary.push_str(&format!("**Run ID:** {}\n", state.run_id)); + summary.push_str(&format!("**Started:** {}\n", state.started_at.format("%Y-%m-%d %H:%M:%S UTC"))); + + if let Some(completed) = state.completed_at { + summary.push_str(&format!("**Completed:** {}\n", completed.format("%Y-%m-%d %H:%M:%S UTC"))); + let duration = completed.signed_duration_since(state.started_at); + summary.push_str(&format!("**Total Duration:** {}\n", format_duration(duration.num_seconds() as u64))); + } + + summary.push_str("\n### Stage Results\n\n"); + summary.push_str("| Stage | Status | Duration | Commits |\n"); + summary.push_str("|-------|--------|----------|---------|\n"); + + let mut total_commits = 0u32; + let mut completed_count = 0; + let mut failed_count = 0; + let mut skipped_count = 0; + + for (i, stage_def) in PIPELINE_STAGES.iter().enumerate() { + let stage_state = &state.stages[i]; + + let (status_str, duration_str, commits_str) = match &stage_state.status { + StageStatus::Pending => ("⏳ Pending".to_string(), "-".to_string(), "-".to_string()), + StageStatus::Running => ("🔄 Running".to_string(), "-".to_string(), "-".to_string()), + StageStatus::Complete { duration_secs, commits_processed } => { + completed_count += 1; + total_commits += commits_processed; + ( + "✅ Complete".to_string(), + format_duration(*duration_secs), + commits_processed.to_string(), + ) + } + StageStatus::Failed { error: _, attempts } => { + failed_count += 1; + ( + format!("❌ Failed ({}x)", attempts), + "-".to_string(), + "-".to_string(), + ) + } + StageStatus::Skipped { reason: _ } => { + skipped_count += 1; + ( + format!("⊘ Skipped"), + "-".to_string(), + "-".to_string(), + ) + } + }; + + summary.push_str(&format!( + "| {} | {} | {} | {} |\n", + stage_def.name, status_str, duration_str, commits_str + )); + } + + summary.push_str("\n### Summary\n\n"); + summary.push_str(&format!("- **Completed:** {} stages\n", completed_count)); + summary.push_str(&format!("- **Failed:** {} stages\n", failed_count)); + summary.push_str(&format!("- **Skipped:** {} stages\n", skipped_count)); + summary.push_str(&format!("- **Total Commits Processed:** {}\n", total_commits)); + + summary +} + +/// Format seconds as human-readable duration +fn format_duration(secs: u64) -> String { + if secs < 60 { + format!("{}s", secs) + } else if secs < 3600 { + format!("{}m {}s", secs / 60, secs % 60) + } else { + format!("{}h {}m", secs / 3600, (secs % 3600) / 60) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_pipeline_stages_order() { + let names: Vec<_> = PIPELINE_STAGES.iter().map(|s| s.name).collect(); + assert_eq!( + names, + vec!["euler", "breaker", "hopper", "fowler", "carmack", "lamport", "huffman"] + ); + } + + #[test] + fn test_pipeline_state_new() { + let state = PipelineState::new(10); + assert_eq!(state.stages.len(), 7); + assert_eq!(state.current_stage, 0); + assert_eq!(state.commits_per_run, 10); + assert!(state.stages.iter().all(|s| s.status == StageStatus::Pending)); + } + + #[test] + fn test_pipeline_state_save_load() { + let temp_dir = TempDir::new().unwrap(); + let repo_root = temp_dir.path(); + + let state = PipelineState::new(10); + state.save(repo_root).unwrap(); + + let loaded = PipelineState::load(repo_root).unwrap().unwrap(); + assert_eq!(loaded.run_id, state.run_id); + assert_eq!(loaded.stages.len(), 7); + } + + #[test] + fn test_pipeline_state_missing_returns_none() { + let temp_dir = TempDir::new().unwrap(); + let result = PipelineState::load(temp_dir.path()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_pipeline_state_corrupted_returns_none() { + let temp_dir = TempDir::new().unwrap(); + let repo_root = temp_dir.path(); + + // Create corrupted state file + let dir = PipelineState::sdlc_dir(repo_root); + fs::create_dir_all(&dir).unwrap(); + fs::write(PipelineState::state_path(repo_root), "not valid json").unwrap(); + + let result = PipelineState::load(repo_root).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_mark_complete_advances_stage() { + let mut state = PipelineState::new(10); + assert_eq!(state.current_stage, 0); + + state.mark_running(); + state.mark_complete(60, 5, "abc123"); + + assert_eq!(state.current_stage, 1); + assert!(matches!(state.stages[0].status, StageStatus::Complete { .. })); + assert_eq!(state.commit_cursor, Some("abc123".to_string())); + } + + #[test] + fn test_mark_failed_tracks_attempts() { + let mut state = PipelineState::new(10); + + state.mark_failed("error 1"); + if let StageStatus::Failed { attempts, .. } = &state.stages[0].status { + assert_eq!(*attempts, 1); + } else { + panic!("Expected Failed status"); + } + + state.mark_failed("error 2"); + if let StageStatus::Failed { attempts, .. } = &state.stages[0].status { + assert_eq!(*attempts, 2); + } else { + panic!("Expected Failed status"); + } + } + + #[test] + fn test_retry_stage() { + let mut state = PipelineState::new(10); + state.mark_failed("some error"); + + state.retry_stage().unwrap(); + assert_eq!(state.stages[0].status, StageStatus::Pending); + } + + #[test] + fn test_retry_non_failed_stage_errors() { + let mut state = PipelineState::new(10); + let result = state.retry_stage(); + assert!(result.is_err()); + } + + #[test] + fn test_find_resume_point() { + let mut state = PipelineState::new(10); + + // Complete first two stages + state.mark_running(); + state.mark_complete(60, 5, "abc"); + state.mark_running(); + state.mark_complete(60, 5, "def"); + + // Fail the third + state.mark_failed("error"); + + assert_eq!(state.find_resume_point(), 2); + } + + #[test] + fn test_resume_from_running_state() { + let mut state = PipelineState::new(10); + state.mark_running(); + + // Simulate crash - stage is still "running" + state.resume(); + + assert_eq!(state.current_stage, 0); + assert_eq!(state.stages[0].status, StageStatus::Pending); + } + + #[test] + fn test_is_complete() { + let mut state = PipelineState::new(10); + assert!(!state.is_complete()); + + // Complete all stages + for _ in 0..7 { + state.mark_running(); + state.mark_complete(60, 5, "abc"); + } + + assert!(state.is_complete()); + } + + #[test] + fn test_get_stage() { + assert!(get_stage("euler").is_some()); + assert!(get_stage("unknown").is_none()); + } + + #[test] + fn test_get_stage_index() { + assert_eq!(get_stage_index("euler"), Some(0)); + assert_eq!(get_stage_index("breaker"), Some(1)); + assert_eq!(get_stage_index("huffman"), Some(6)); + assert_eq!(get_stage_index("unknown"), None); + } +}