Skip to main content

autom8/output/
status.rs

1//! Status and project display.
2//!
3//! Output functions for displaying run status, project trees, and descriptions.
4
5use crate::state::{MachineState, RunState, RunStatus, SessionStatus};
6use chrono::Utc;
7
8use super::colors::*;
9
10const WARNING_PANEL_WIDTH: usize = 60;
11
12/// Print current run status.
13pub fn print_status(state: &RunState) {
14    println!("{BLUE}Run ID:{RESET}    {}", state.run_id);
15    println!("{BLUE}Status:{RESET}    {:?}", state.status);
16    println!("{BLUE}Spec:{RESET}      {}", state.spec_json_path.display());
17    println!("{BLUE}Branch:{RESET}    {}", state.branch);
18    if let Some(story) = &state.current_story {
19        println!("{BLUE}Current:{RESET}   {}", story);
20    }
21    println!("{BLUE}Task:{RESET}      {}", state.iteration);
22    println!(
23        "{BLUE}Started:{RESET}   {}",
24        state.started_at.format("%Y-%m-%d %H:%M:%S")
25    );
26    println!("{BLUE}Tasks run:{RESET}  {}", state.iterations.len());
27}
28
29/// Print global status across all projects.
30pub fn print_global_status(statuses: &[crate::config::ProjectStatus]) {
31    if statuses.is_empty() {
32        println!("{GRAY}No projects found.{RESET}");
33        println!();
34        println!("Run {CYAN}autom8{RESET} in a project directory to create a project.");
35        return;
36    }
37
38    let (needs_attention, idle): (Vec<_>, Vec<_>) =
39        statuses.iter().partition(|s| s.needs_attention());
40
41    if !needs_attention.is_empty() {
42        println!("{BOLD}Projects needing attention:{RESET}");
43        println!();
44
45        for status in &needs_attention {
46            let status_indicator = match status.run_status {
47                Some(RunStatus::Running) => format!("{YELLOW}[running]{RESET}"),
48                Some(RunStatus::Failed) => format!("{RED}[failed]{RESET}"),
49                Some(RunStatus::Interrupted) => format!("{YELLOW}[interrupted]{RESET}"),
50                Some(RunStatus::Completed) => String::new(),
51                None => String::new(),
52            };
53
54            let spec_info = if status.incomplete_spec_count > 0 {
55                format!(
56                    " {CYAN}{} incomplete spec{}{RESET}",
57                    status.incomplete_spec_count,
58                    if status.incomplete_spec_count == 1 {
59                        ""
60                    } else {
61                        "s"
62                    }
63                )
64            } else {
65                String::new()
66            };
67
68            if status_indicator.is_empty() {
69                println!("  {BOLD}{}{RESET}{}", status.name, spec_info);
70            } else {
71                println!(
72                    "  {BOLD}{}{RESET} {}{}",
73                    status.name, status_indicator, spec_info
74                );
75            }
76        }
77        println!();
78    }
79
80    if !idle.is_empty() {
81        println!("{GRAY}Idle projects:{RESET}");
82        for status in &idle {
83            println!("{GRAY}  {}{RESET}", status.name);
84        }
85        println!();
86    }
87
88    let active_count = statuses
89        .iter()
90        .filter(|s| s.run_status == Some(RunStatus::Running))
91        .count();
92    let failed_count = statuses
93        .iter()
94        .filter(|s| s.run_status == Some(RunStatus::Failed))
95        .count();
96    let incomplete_spec_total: usize = statuses.iter().map(|s| s.incomplete_spec_count).sum();
97
98    println!(
99        "{GRAY}({} project{}, {} active, {} failed, {} incomplete spec{}){RESET}",
100        statuses.len(),
101        if statuses.len() == 1 { "" } else { "s" },
102        active_count,
103        failed_count,
104        incomplete_spec_total,
105        if incomplete_spec_total == 1 { "" } else { "s" }
106    );
107}
108
109/// Print a tree view of all projects in the config directory.
110pub fn print_project_tree(projects: &[crate::config::ProjectTreeInfo]) {
111    if projects.is_empty() {
112        println!("{GRAY}No projects found in ~/.config/autom8/{RESET}");
113        println!();
114        println!("Run {CYAN}autom8{RESET} in a project directory to create a project.");
115        return;
116    }
117
118    println!("{BOLD}~/.config/autom8/{RESET}");
119
120    let total = projects.len();
121
122    for (idx, project) in projects.iter().enumerate() {
123        let is_last_project = idx == total - 1;
124        let branch_char = if is_last_project { "└" } else { "├" };
125        let cont_char = if is_last_project { " " } else { "│" };
126
127        let (status_indicator, status_color) = match project.run_status {
128            Some(RunStatus::Running) => ("[running]", YELLOW),
129            Some(RunStatus::Failed) => ("[failed]", RED),
130            Some(RunStatus::Interrupted) => ("[interrupted]", YELLOW),
131            Some(RunStatus::Completed) if project.incomplete_spec_count > 0 => {
132                ("[incomplete]", CYAN)
133            }
134            Some(RunStatus::Completed) => ("[complete]", GREEN),
135            None if project.incomplete_spec_count > 0 => ("[incomplete]", CYAN),
136            None if project.has_content() => ("[idle]", GRAY),
137            None => ("", GRAY),
138        };
139
140        if status_indicator.is_empty() {
141            println!("{branch_char}── {BOLD}{}{RESET}", project.name);
142        } else {
143            println!(
144                "{branch_char}── {BOLD}{}{RESET} {status_color}{status_indicator}{RESET}",
145                project.name
146            );
147        }
148
149        let subdirs = [
150            ("spec", project.spec_md_count, "md"),
151            ("spec", project.spec_count, "json"),
152            ("runs", project.runs_count, "archived"),
153        ];
154
155        for (subidx, (name, count, unit)) in subdirs.iter().enumerate() {
156            let is_last_subdir = subidx == subdirs.len() - 1;
157            let sub_branch = if is_last_subdir { "└" } else { "├" };
158
159            let count_str = if *count == 0 {
160                format!("{GRAY}(empty){RESET}")
161            } else if *count == 1 {
162                format!("{GRAY}(1 {unit}){RESET}")
163            } else {
164                format!("{GRAY}({count} {unit}s){RESET}")
165            };
166
167            println!("{cont_char}   {sub_branch}── {name}/     {count_str}");
168        }
169
170        if !is_last_project {
171            println!("{cont_char}");
172        }
173    }
174
175    println!();
176    let active_count = projects.iter().filter(|p| p.has_active_run).count();
177    let failed_count = projects
178        .iter()
179        .filter(|p| p.run_status == Some(RunStatus::Failed))
180        .count();
181    let incomplete_total: usize = projects.iter().map(|p| p.incomplete_spec_count).sum();
182
183    println!(
184        "{GRAY}({} project{}, {} active, {} failed, {} incomplete spec{}){RESET}",
185        total,
186        if total == 1 { "" } else { "s" },
187        active_count,
188        failed_count,
189        incomplete_total,
190        if incomplete_total == 1 { "" } else { "s" }
191    );
192}
193
194/// Print detailed description of a project.
195pub fn print_project_description(desc: &crate::config::ProjectDescription) {
196    println!("{BOLD}Project: {CYAN}{}{RESET}", desc.name);
197    println!("{GRAY}Path: {}{RESET}", desc.path.display());
198    println!();
199
200    let status_indicator = match desc.run_status {
201        Some(RunStatus::Running) => format!("{YELLOW}[running]{RESET}"),
202        Some(RunStatus::Failed) => format!("{RED}[failed]{RESET}"),
203        Some(RunStatus::Interrupted) => format!("{YELLOW}[interrupted]{RESET}"),
204        Some(RunStatus::Completed) => format!("{GREEN}[completed]{RESET}"),
205        None => format!("{GRAY}[idle]{RESET}"),
206    };
207    println!("{BOLD}Status:{RESET} {}", status_indicator);
208
209    if let Some(branch) = &desc.current_branch {
210        println!("{BLUE}Branch:{RESET} {}", branch);
211    }
212
213    if let Some(story) = &desc.current_story {
214        println!("{BLUE}Current Story:{RESET} {}", story);
215    }
216    println!();
217
218    if desc.specs.is_empty() {
219        println!("{GRAY}No specs found.{RESET}");
220    } else {
221        println!("{BOLD}Specs:{RESET} ({} total)", desc.specs.len());
222        println!();
223
224        for spec in &desc.specs {
225            print_spec_summary(spec);
226        }
227    }
228
229    println!("{GRAY}─────────────────────────────────────────────────────────{RESET}");
230    println!(
231        "{GRAY}Files: {} spec md, {} spec json, {} archived runs{RESET}",
232        desc.spec_md_count,
233        desc.specs.len(),
234        desc.runs_count
235    );
236}
237
238/// Print summary of a single spec.
239///
240/// Shows full details (with user stories) only for the active spec.
241/// All other specs (including when no spec is active) show condensed view.
242fn print_spec_summary(spec: &crate::config::SpecSummary) {
243    // Show "(Active)" indicator for the active spec
244    let active_label = if spec.is_active {
245        format!(" {YELLOW}(Active){RESET}")
246    } else {
247        String::new()
248    };
249
250    println!(
251        "{CYAN}━━━{RESET} {BOLD}{}{RESET}{}",
252        spec.filename, active_label
253    );
254
255    // Only show full details for the active spec
256    // All other specs (or when no spec is active) show condensed view
257    if !spec.is_active {
258        let desc_preview = if spec.description.len() > 80 {
259            format!("{}...", &spec.description[..80])
260        } else {
261            spec.description.clone()
262        };
263        let first_line = desc_preview.lines().next().unwrap_or(&desc_preview);
264        println!("{GRAY}{}{RESET}", first_line);
265        println!(
266            "{GRAY}({}/{} stories complete){RESET}",
267            spec.completed_count, spec.total_count
268        );
269        println!();
270        return;
271    }
272
273    // Full display for active spec only
274    println!("{BLUE}Project:{RESET} {}", spec.project_name);
275    println!("{BLUE}Branch:{RESET}  {}", spec.branch_name);
276
277    let desc_preview = if spec.description.len() > 100 {
278        format!("{}...", &spec.description[..100])
279    } else {
280        spec.description.clone()
281    };
282    let first_line = desc_preview.lines().next().unwrap_or(&desc_preview);
283    println!("{BLUE}Description:{RESET} {}", first_line);
284    println!();
285
286    let progress_bar = make_progress_bar_simple(spec.completed_count, spec.total_count, 12);
287    let progress_color = if spec.completed_count == spec.total_count {
288        GREEN
289    } else if spec.completed_count == 0 {
290        GRAY
291    } else {
292        YELLOW
293    };
294    println!(
295        "{BOLD}Progress:{RESET} [{}] {}{}/{} stories complete{}",
296        progress_bar, progress_color, spec.completed_count, spec.total_count, RESET
297    );
298    println!();
299
300    println!("{BOLD}User Stories:{RESET}");
301    for story in &spec.stories {
302        let status_icon = if story.passes {
303            format!("{GREEN}✓{RESET}")
304        } else {
305            format!("{GRAY}○{RESET}")
306        };
307        let title_color = if story.passes { GREEN } else { RESET };
308        println!(
309            "  {} {BOLD}{}{RESET}: {}{}{}",
310            status_icon, story.id, title_color, story.title, RESET
311        );
312    }
313    println!();
314}
315
316fn make_progress_bar_simple(completed: usize, total: usize, width: usize) -> String {
317    if total == 0 {
318        return " ".repeat(width);
319    }
320    let filled = (completed * width) / total;
321    let empty = width - filled;
322    format!(
323        "{GREEN}{}{RESET}{GRAY}{}{RESET}",
324        "█".repeat(filled),
325        "░".repeat(empty)
326    )
327}
328
329/// Print history entry.
330pub fn print_history_entry(state: &RunState, index: usize) {
331    let status_color = match state.status {
332        RunStatus::Completed => GREEN,
333        RunStatus::Failed => RED,
334        _ => YELLOW,
335    };
336    println!(
337        "{}. [{}{:?}{}] {} - {} ({} tasks)",
338        index + 1,
339        status_color,
340        state.status,
341        RESET,
342        state.started_at.format("%Y-%m-%d %H:%M"),
343        state.branch,
344        state.iterations.len()
345    );
346}
347
348/// Print a prominent warning panel for missing spec file.
349pub fn print_missing_spec_warning(branch_name: &str, spec_path: &str) {
350    let top_border = format!("╔{}╗", "═".repeat(WARNING_PANEL_WIDTH - 2));
351    let bottom_border = format!("╚{}╝", "═".repeat(WARNING_PANEL_WIDTH - 2));
352    let separator = format!("╟{}╢", "─".repeat(WARNING_PANEL_WIDTH - 2));
353
354    println!();
355    println!("{YELLOW}{BOLD}{}{RESET}", top_border);
356
357    let header = " ⚠  NO SPEC FILE FOUND ";
358    let header_padding = WARNING_PANEL_WIDTH.saturating_sub(header.len() + 2);
359    let left_pad = header_padding / 2;
360    let right_pad = header_padding - left_pad;
361    println!(
362        "{YELLOW}{BOLD}║{}{}{}║{RESET}",
363        " ".repeat(left_pad),
364        header,
365        " ".repeat(right_pad)
366    );
367
368    println!("{YELLOW}{}{RESET}", separator);
369
370    print_warning_panel_line("The PR review will proceed with reduced context.");
371    print_warning_panel_line("");
372    print_warning_panel_line(&format!("Branch: {}", branch_name));
373
374    let max_path_len = WARNING_PANEL_WIDTH - 12;
375    let display_path = if spec_path.len() > max_path_len {
376        format!("...{}", &spec_path[spec_path.len() - max_path_len + 3..])
377    } else {
378        spec_path.to_string()
379    };
380    print_warning_panel_line(&format!("Expected: {}", display_path));
381
382    println!("{YELLOW}{}{RESET}", separator);
383
384    print_warning_panel_line("Create a spec file to provide full context:");
385    print_warning_panel_line("  autom8 --spec <spec.md>");
386
387    println!("{YELLOW}{BOLD}{}{RESET}", bottom_border);
388    println!();
389}
390
391fn print_warning_panel_line(text: &str) {
392    let max_width = WARNING_PANEL_WIDTH - 4;
393    let display_text = if text.len() > max_width {
394        &text[..max_width]
395    } else {
396        text
397    };
398    let padding = max_width.saturating_sub(display_text.len());
399    println!(
400        "{YELLOW}║{RESET} {}{} {YELLOW}║{RESET}",
401        display_text,
402        " ".repeat(padding)
403    );
404}
405
406/// Print a summary of the branch context being used.
407pub fn print_branch_context_summary(has_spec: bool, commit_count: usize, branch_name: &str) {
408    println!();
409    println!("{CYAN}Branch Context:{RESET} {}", branch_name);
410
411    if has_spec {
412        println!("{GREEN}  ✓ Spec file loaded{RESET}");
413    } else {
414        println!("{YELLOW}  ⚠ No spec file (reduced context){RESET}");
415    }
416
417    println!(
418        "{BLUE}  {} commit{} on branch{RESET}",
419        commit_count,
420        if commit_count == 1 { "" } else { "s" }
421    );
422    println!();
423}
424
425/// Print a list of commits for display.
426pub fn print_commit_list(commits: &[crate::git::CommitInfo], max_display: usize) {
427    if commits.is_empty() {
428        println!("{GRAY}No commits found on this branch.{RESET}");
429        return;
430    }
431
432    let display_count = commits.len().min(max_display);
433    println!("{BOLD}Recent Commits:{RESET}");
434
435    for commit in commits.iter().take(display_count) {
436        let max_msg_len = 50;
437        let display_msg = if commit.message.len() > max_msg_len {
438            format!("{}...", &commit.message[..max_msg_len - 3])
439        } else {
440            commit.message.clone()
441        };
442
443        println!("  {CYAN}{}{RESET} {}", commit.short_hash, display_msg);
444    }
445
446    if commits.len() > max_display {
447        println!(
448            "{GRAY}  ... and {} more commit{}{RESET}",
449            commits.len() - max_display,
450            if commits.len() - max_display == 1 {
451                ""
452            } else {
453                "s"
454            }
455        );
456    }
457    println!();
458}
459
460/// Print status for all sessions in a project.
461///
462/// Sessions are displayed with the current session highlighted, including:
463/// - Session ID and worktree path
464/// - Branch name and current state
465/// - Current story (if any)
466/// - Duration since start
467pub fn print_sessions_status(sessions: &[SessionStatus]) {
468    println!("{BOLD}Sessions for this project:{RESET}");
469    println!();
470
471    for session in sessions {
472        print_session_row(session);
473    }
474
475    // Summary line
476    let running_count = sessions
477        .iter()
478        .filter(|s| s.metadata.is_running && !s.is_stale)
479        .count();
480    let stale_count = sessions.iter().filter(|s| s.is_stale).count();
481
482    println!();
483    print!(
484        "{GRAY}({} session{}",
485        sessions.len(),
486        if sessions.len() == 1 { "" } else { "s" }
487    );
488    if running_count > 0 {
489        print!(", {} running", running_count);
490    }
491    if stale_count > 0 {
492        print!(", {} stale", stale_count);
493    }
494    println!("){RESET}");
495}
496
497/// Print a single session row.
498fn print_session_row(session: &SessionStatus) {
499    let metadata = &session.metadata;
500
501    // Determine row color based on state
502    let (indicator, indicator_color) = if session.is_stale {
503        ("✗", GRAY)
504    } else if session.is_current {
505        ("→", GREEN)
506    } else if metadata.is_running {
507        ("●", YELLOW)
508    } else {
509        ("○", GRAY)
510    };
511
512    // Session ID and current marker
513    let current_marker = if session.is_current { " (current)" } else { "" };
514    let stale_marker = if session.is_stale { " [stale]" } else { "" };
515
516    println!(
517        "{indicator_color}{indicator}{RESET} {BOLD}{}{RESET}{GREEN}{}{RESET}{GRAY}{}{RESET}",
518        metadata.session_id, current_marker, stale_marker
519    );
520
521    // Worktree path (truncated if too long)
522    let path_str = metadata.worktree_path.display().to_string();
523    let display_path = if path_str.len() > 60 {
524        format!("...{}", &path_str[path_str.len() - 57..])
525    } else {
526        path_str
527    };
528    println!("  {GRAY}Path:{RESET}    {}", display_path);
529
530    // Branch name
531    println!("  {BLUE}Branch:{RESET}  {}", metadata.branch_name);
532
533    // Current state
534    if let Some(state) = &session.machine_state {
535        let state_str = format_machine_state(state);
536        let state_color = machine_state_color(state);
537        println!("  {BLUE}State:{RESET}   {state_color}{}{RESET}", state_str);
538    }
539
540    // Current story (if any)
541    if let Some(story) = &session.current_story {
542        println!("  {BLUE}Story:{RESET}   {}", story);
543    }
544
545    // Duration
546    let duration = format_duration(metadata.created_at, metadata.last_active_at);
547    println!(
548        "  {GRAY}Started:{RESET} {} {}",
549        metadata.created_at.format("%Y-%m-%d %H:%M"),
550        duration
551    );
552
553    println!();
554}
555
556/// Format machine state for display.
557fn format_machine_state(state: &MachineState) -> &'static str {
558    match state {
559        MachineState::Idle => "Idle",
560        MachineState::LoadingSpec => "Loading Spec",
561        MachineState::GeneratingSpec => "Generating Spec",
562        MachineState::Initializing => "Initializing",
563        MachineState::PickingStory => "Picking Story",
564        MachineState::RunningClaude => "Running Claude",
565        MachineState::Reviewing => "Reviewing",
566        MachineState::Correcting => "Correcting",
567        MachineState::Committing => "Committing",
568        MachineState::CreatingPR => "Creating PR",
569        MachineState::Completed => "Completed",
570        MachineState::Failed => "Failed",
571    }
572}
573
574/// Get color for machine state.
575fn machine_state_color(state: &MachineState) -> &'static str {
576    match state {
577        MachineState::Completed => GREEN,
578        MachineState::Failed => RED,
579        MachineState::RunningClaude | MachineState::Reviewing | MachineState::Correcting => YELLOW,
580        _ => CYAN,
581    }
582}
583
584/// Format duration since session start.
585fn format_duration(
586    created_at: chrono::DateTime<chrono::Utc>,
587    last_active_at: chrono::DateTime<chrono::Utc>,
588) -> String {
589    let now = Utc::now();
590    let duration = now.signed_duration_since(created_at);
591
592    // Calculate active duration
593    let active_duration = last_active_at.signed_duration_since(created_at);
594
595    let days = duration.num_days();
596    let hours = duration.num_hours() % 24;
597    let minutes = duration.num_minutes() % 60;
598
599    let age_str = if days > 0 {
600        format!("{}d {}h ago", days, hours)
601    } else if hours > 0 {
602        format!("{}h {}m ago", hours, minutes)
603    } else if minutes > 0 {
604        format!("{}m ago", minutes)
605    } else {
606        "just now".to_string()
607    };
608
609    // Show active duration if significantly different from total
610    let active_hours = active_duration.num_hours();
611    let active_mins = active_duration.num_minutes() % 60;
612    if active_hours > 0 {
613        format!("{} (active {}h {}m)", age_str, active_hours, active_mins)
614    } else if active_mins > 5 {
615        format!("{} (active {}m)", age_str, active_mins)
616    } else {
617        age_str
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624    use crate::state::SessionMetadata;
625    use std::path::PathBuf;
626
627    fn make_session_status(
628        session_id: &str,
629        branch: &str,
630        is_current: bool,
631        is_stale: bool,
632        is_running: bool,
633        machine_state: Option<MachineState>,
634        current_story: Option<&str>,
635    ) -> SessionStatus {
636        SessionStatus {
637            metadata: SessionMetadata {
638                session_id: session_id.to_string(),
639                worktree_path: PathBuf::from(format!("/projects/test-wt-{}", session_id)),
640                branch_name: branch.to_string(),
641                created_at: Utc::now(),
642                last_active_at: Utc::now(),
643                is_running,
644                spec_json_path: None,
645            },
646            machine_state,
647            current_story: current_story.map(|s| s.to_string()),
648            is_current,
649            is_stale,
650        }
651    }
652
653    // ======================================================================
654    // Tests for US-006: Verify status --all command output
655    // ======================================================================
656
657    #[test]
658    fn test_us006_format_machine_state_all_variants() {
659        // Verify all machine states have display names
660        assert_eq!(format_machine_state(&MachineState::Idle), "Idle");
661        assert_eq!(
662            format_machine_state(&MachineState::LoadingSpec),
663            "Loading Spec"
664        );
665        assert_eq!(
666            format_machine_state(&MachineState::GeneratingSpec),
667            "Generating Spec"
668        );
669        assert_eq!(
670            format_machine_state(&MachineState::Initializing),
671            "Initializing"
672        );
673        assert_eq!(
674            format_machine_state(&MachineState::PickingStory),
675            "Picking Story"
676        );
677        assert_eq!(
678            format_machine_state(&MachineState::RunningClaude),
679            "Running Claude"
680        );
681        assert_eq!(format_machine_state(&MachineState::Reviewing), "Reviewing");
682        assert_eq!(
683            format_machine_state(&MachineState::Correcting),
684            "Correcting"
685        );
686        assert_eq!(
687            format_machine_state(&MachineState::Committing),
688            "Committing"
689        );
690        assert_eq!(
691            format_machine_state(&MachineState::CreatingPR),
692            "Creating PR"
693        );
694        assert_eq!(format_machine_state(&MachineState::Completed), "Completed");
695        assert_eq!(format_machine_state(&MachineState::Failed), "Failed");
696    }
697
698    #[test]
699    fn test_us006_machine_state_colors() {
700        // Verify appropriate colors for different states
701        assert_eq!(machine_state_color(&MachineState::Completed), GREEN);
702        assert_eq!(machine_state_color(&MachineState::Failed), RED);
703        assert_eq!(machine_state_color(&MachineState::RunningClaude), YELLOW);
704        assert_eq!(machine_state_color(&MachineState::Reviewing), YELLOW);
705        assert_eq!(machine_state_color(&MachineState::Correcting), YELLOW);
706        // Other states use CYAN
707        assert_eq!(machine_state_color(&MachineState::Idle), CYAN);
708        assert_eq!(machine_state_color(&MachineState::Initializing), CYAN);
709    }
710
711    #[test]
712    fn test_us006_session_row_current_marker() {
713        // Test that current session gets → indicator and (current) marker
714        let session = make_session_status(
715            "main",
716            "feature/test",
717            true,  // is_current
718            false, // is_stale
719            true,  // is_running
720            Some(MachineState::RunningClaude),
721            Some("US-001"),
722        );
723
724        // Verify the logic for current marker
725        let current_marker = if session.is_current { " (current)" } else { "" };
726        assert_eq!(current_marker, " (current)");
727
728        // Verify indicator for current session
729        let (indicator, _) = if session.is_stale {
730            ("✗", GRAY)
731        } else if session.is_current {
732            ("→", GREEN)
733        } else if session.metadata.is_running {
734            ("●", YELLOW)
735        } else {
736            ("○", GRAY)
737        };
738        assert_eq!(indicator, "→");
739    }
740
741    #[test]
742    fn test_us006_session_row_stale_marker() {
743        // Test that stale sessions get [stale] marker
744        let session = make_session_status(
745            "abc12345",
746            "feature/old",
747            false, // is_current
748            true,  // is_stale
749            true,  // is_running
750            Some(MachineState::RunningClaude),
751            None,
752        );
753
754        let stale_marker = if session.is_stale { " [stale]" } else { "" };
755        assert_eq!(stale_marker, " [stale]");
756
757        // Stale sessions get ✗ indicator regardless of other status
758        let (indicator, indicator_color) = if session.is_stale {
759            ("✗", GRAY)
760        } else if session.is_current {
761            ("→", GREEN)
762        } else if session.metadata.is_running {
763            ("●", YELLOW)
764        } else {
765            ("○", GRAY)
766        };
767        assert_eq!(indicator, "✗");
768        assert_eq!(indicator_color, GRAY);
769    }
770
771    #[test]
772    fn test_us006_session_row_running_indicator() {
773        // Test that running (but not current) sessions get ● indicator
774        let session = make_session_status(
775            "session1",
776            "feature/parallel",
777            false, // is_current
778            false, // is_stale
779            true,  // is_running
780            Some(MachineState::Reviewing),
781            Some("US-002"),
782        );
783
784        let (indicator, indicator_color) = if session.is_stale {
785            ("✗", GRAY)
786        } else if session.is_current {
787            ("→", GREEN)
788        } else if session.metadata.is_running {
789            ("●", YELLOW)
790        } else {
791            ("○", GRAY)
792        };
793        assert_eq!(indicator, "●");
794        assert_eq!(indicator_color, YELLOW);
795    }
796
797    #[test]
798    fn test_us006_session_row_idle_indicator() {
799        // Test that idle sessions get ○ indicator
800        let session = make_session_status(
801            "session2",
802            "feature/done",
803            false, // is_current
804            false, // is_stale
805            false, // is_running
806            Some(MachineState::Completed),
807            None,
808        );
809
810        let (indicator, indicator_color) = if session.is_stale {
811            ("✗", GRAY)
812        } else if session.is_current {
813            ("→", GREEN)
814        } else if session.metadata.is_running {
815            ("●", YELLOW)
816        } else {
817            ("○", GRAY)
818        };
819        assert_eq!(indicator, "○");
820        assert_eq!(indicator_color, GRAY);
821    }
822
823    #[test]
824    fn test_us006_summary_counts() {
825        // Test that summary correctly counts running and stale sessions
826        let sessions = vec![
827            make_session_status(
828                "main",
829                "main",
830                true,
831                false,
832                true,
833                Some(MachineState::RunningClaude),
834                Some("US-001"),
835            ),
836            make_session_status(
837                "session1",
838                "feat-1",
839                false,
840                false,
841                true,
842                Some(MachineState::Reviewing),
843                Some("US-002"),
844            ),
845            make_session_status("session2", "feat-2", false, true, false, None, None), // stale
846            make_session_status(
847                "session3",
848                "feat-3",
849                false,
850                false,
851                false,
852                Some(MachineState::Completed),
853                None,
854            ), // idle
855        ];
856
857        // Running count: sessions that are running AND not stale
858        let running_count = sessions
859            .iter()
860            .filter(|s| s.metadata.is_running && !s.is_stale)
861            .count();
862        assert_eq!(running_count, 2);
863
864        // Stale count
865        let stale_count = sessions.iter().filter(|s| s.is_stale).count();
866        assert_eq!(stale_count, 1);
867
868        // Total count
869        assert_eq!(sessions.len(), 4);
870    }
871
872    #[test]
873    fn test_us006_worktree_path_truncation() {
874        // Test that long worktree paths are truncated properly
875        let long_path =
876            "/very/long/path/that/exceeds/sixty/characters/for/display/purposes/test-worktree";
877        assert!(long_path.len() > 60);
878
879        let display_path = if long_path.len() > 60 {
880            format!("...{}", &long_path[long_path.len() - 57..])
881        } else {
882            long_path.to_string()
883        };
884
885        assert!(display_path.starts_with("..."));
886        assert!(display_path.len() <= 60);
887    }
888
889    #[test]
890    fn test_us006_session_status_displays_all_fields() {
891        // Verify SessionStatus contains all required display fields per acceptance criteria:
892        // - session ID, worktree path, branch, state, current story
893        let session = make_session_status(
894            "abc12345",                        // session_id
895            "feature/test",                    // branch
896            true,                              // is_current (for highlighting)
897            false,                             // is_stale
898            true,                              // is_running
899            Some(MachineState::RunningClaude), // state
900            Some("US-001"),                    // current story
901        );
902
903        // Session ID
904        assert_eq!(session.metadata.session_id, "abc12345");
905
906        // Worktree path
907        assert!(session
908            .metadata
909            .worktree_path
910            .to_string_lossy()
911            .contains("abc12345"));
912
913        // Branch
914        assert_eq!(session.metadata.branch_name, "feature/test");
915
916        // State
917        assert_eq!(session.machine_state, Some(MachineState::RunningClaude));
918
919        // Current story
920        assert_eq!(session.current_story, Some("US-001".to_string()));
921
922        // Current session highlighting
923        assert!(session.is_current);
924    }
925}