Skip to main content

autom8/ui/shared/
mod.rs

1//! Shared data types and logic for UI modules.
2//!
3//! This module contains common data structures used by both the GUI and TUI,
4//! such as run progress, project data, session data, and run history entries.
5//!
6//! These types are framework-agnostic and can be used by any UI implementation.
7
8use crate::config::{list_projects_tree, ProjectTreeInfo};
9use crate::error::Result;
10use crate::spec::{Spec, UserStory};
11use crate::state::{
12    IterationStatus, LiveState, MachineState, RunState, RunStatus, SessionMetadata, StateManager,
13};
14use crate::worktree::MAIN_SESSION_ID;
15use chrono::{DateTime, Utc};
16use std::collections::HashSet;
17use std::path::PathBuf;
18
19// ============================================================================
20// Shared Status Types and Functions
21// ============================================================================
22
23/// Semantic status states for consistent status determination across UIs.
24///
25/// This enum represents the semantic meaning of a run's status, abstracting
26/// away the underlying MachineState details. Both GUI and TUI should use
27/// these states to ensure consistent behavior.
28///
29/// The status values are:
30/// - `Setup`: Gray - setup/initialization phases (Initializing, PickingStory, LoadingSpec, GeneratingSpec)
31/// - `Running`: Blue - active implementation work (RunningClaude)
32/// - `Reviewing`: Amber - evaluation phases (Reviewing)
33/// - `Correcting`: Orange - attention needed, fixes in progress (Correcting)
34/// - `Success`: Green - success path (Committing, CreatingPR, Completed)
35/// - `Error`: Red - failure states (Failed)
36/// - `Warning`: Amber - general warnings (e.g., stuck sessions)
37/// - `Idle`: Gray - inactive state (Idle)
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Status {
40    /// Setup/initialization state - displayed in gray.
41    Setup,
42    /// Active implementation work - displayed in blue.
43    Running,
44    /// Evaluation/review phase - displayed in amber.
45    Reviewing,
46    /// Attention needed, fixes in progress - displayed in orange.
47    Correcting,
48    /// Success path (committing, PR, completed) - displayed in green.
49    Success,
50    /// Warning/attention needed - displayed in amber.
51    Warning,
52    /// Error/failure state - displayed in red.
53    Error,
54    /// Idle/inactive state - displayed in gray.
55    Idle,
56}
57
58impl Status {
59    /// Convert a MachineState to the appropriate Status.
60    ///
61    /// Color mapping follows semantic meaning for state phases:
62    /// - Setup phases (Initializing, PickingStory, LoadingSpec, GeneratingSpec): Gray
63    /// - Active implementation (RunningClaude): Blue
64    /// - Evaluation phase (Reviewing): Amber
65    /// - Attention needed (Correcting): Orange
66    /// - Success path (Committing, CreatingPR, Completed): Green
67    /// - Failure (Failed): Red
68    /// - Inactive (Idle): Gray
69    pub fn from_machine_state(state: MachineState) -> Self {
70        match state {
71            // Setup phases - gray (preparation work)
72            MachineState::Initializing
73            | MachineState::PickingStory
74            | MachineState::LoadingSpec
75            | MachineState::GeneratingSpec => Status::Setup,
76
77            // Active implementation - blue
78            MachineState::RunningClaude => Status::Running,
79
80            // Evaluation phase - amber
81            MachineState::Reviewing => Status::Reviewing,
82
83            // Attention needed - orange
84            MachineState::Correcting => Status::Correcting,
85
86            // Success path - green
87            MachineState::Committing | MachineState::CreatingPR | MachineState::Completed => {
88                Status::Success
89            }
90
91            // Failure - red
92            MachineState::Failed => Status::Error,
93
94            // Inactive - gray
95            MachineState::Idle => Status::Idle,
96        }
97    }
98}
99
100/// Format a MachineState as a human-readable label.
101///
102/// This is the canonical state label formatting used by both GUI and TUI
103/// to ensure consistent state display across the application.
104pub fn format_state_label(state: MachineState) -> &'static str {
105    match state {
106        MachineState::Idle => "Idle",
107        MachineState::LoadingSpec => "Loading Spec",
108        MachineState::GeneratingSpec => "Generating Spec",
109        MachineState::Initializing => "Initializing",
110        MachineState::PickingStory => "Picking Story",
111        MachineState::RunningClaude => "Running Claude",
112        MachineState::Reviewing => "Reviewing",
113        MachineState::Correcting => "Correcting",
114        MachineState::Committing => "Committing",
115        MachineState::CreatingPR => "Creating PR",
116        MachineState::Completed => "Completed",
117        MachineState::Failed => "Failed",
118    }
119}
120
121/// Format a duration from a start time as a human-readable string.
122///
123/// Examples: "5s", "2m 30s", "1h 5m"
124///
125/// This is the canonical duration formatting used by both GUI and TUI.
126pub fn format_duration(started_at: DateTime<Utc>) -> String {
127    let now = Utc::now();
128    let duration = now.signed_duration_since(started_at);
129    format_duration_secs(duration.num_seconds().max(0) as u64)
130}
131
132/// Format a run duration, using `finished_at` if available, otherwise live from now.
133///
134/// When `finished_at` is `Some`, computes a fixed duration `finished_at - started_at`.
135/// When `finished_at` is `None`, falls back to `Utc::now() - started_at` (live counter).
136pub fn format_run_duration(
137    started_at: DateTime<Utc>,
138    finished_at: Option<DateTime<Utc>>,
139) -> String {
140    let end = finished_at.unwrap_or_else(Utc::now);
141    let duration = end.signed_duration_since(started_at);
142    format_duration_secs(duration.num_seconds().max(0) as u64)
143}
144
145/// Format a duration in seconds as a human-readable string.
146///
147/// - Durations under 1 minute show only seconds
148/// - Durations between 1-60 minutes show minutes and seconds
149/// - Durations over 1 hour show hours and minutes (no seconds)
150pub fn format_duration_secs(total_secs: u64) -> String {
151    let hours = total_secs / 3600;
152    let minutes = (total_secs % 3600) / 60;
153    let seconds = total_secs % 60;
154
155    if hours > 0 {
156        format!("{}h {}m", hours, minutes)
157    } else if minutes > 0 {
158        format!("{}m {}s", minutes, seconds)
159    } else {
160        format!("{}s", seconds)
161    }
162}
163
164/// Format a timestamp as a relative time string.
165///
166/// Examples: "just now", "5m ago", "2h ago", "3d ago"
167///
168/// This is the canonical relative time formatting used by both GUI and TUI.
169pub fn format_relative_time(timestamp: DateTime<Utc>) -> String {
170    let now = Utc::now();
171    let duration = now.signed_duration_since(timestamp);
172    format_relative_time_secs(duration.num_seconds().max(0) as u64)
173}
174
175/// Format a relative time from seconds ago.
176pub fn format_relative_time_secs(total_secs: u64) -> String {
177    let minutes = total_secs / 60;
178    let hours = total_secs / 3600;
179    let days = total_secs / 86400;
180
181    if days > 0 {
182        format!("{}d ago", days)
183    } else if hours > 0 {
184        format!("{}h ago", hours)
185    } else if minutes > 0 {
186        format!("{}m ago", minutes)
187    } else {
188        "just now".to_string()
189    }
190}
191
192// ============================================================================
193// Shared Data Types
194// ============================================================================
195
196/// Progress information for a run.
197///
198/// This is the canonical progress struct used by both GUI and TUI.
199/// It provides methods for calculating and formatting progress values.
200#[derive(Debug, Clone, Copy)]
201pub struct RunProgress {
202    /// Number of completed stories.
203    pub completed: usize,
204    /// Total number of stories.
205    pub total: usize,
206}
207
208impl RunProgress {
209    /// Create a new RunProgress instance.
210    pub fn new(completed: usize, total: usize) -> Self {
211        Self { completed, total }
212    }
213
214    /// Calculate the progress as a fraction between 0.0 and 1.0.
215    pub fn fraction(&self) -> f32 {
216        if self.total == 0 {
217            0.0
218        } else {
219            (self.completed as f32) / (self.total as f32)
220        }
221    }
222
223    /// Format progress as a story fraction string (e.g., "Story 2/5").
224    /// The current story number is completed + 1 (1-indexed), but capped at total
225    /// to avoid displaying impossible values like "Story 8/7" at completion.
226    pub fn as_fraction(&self) -> String {
227        let current = if self.completed < self.total {
228            self.completed + 1
229        } else {
230            self.total
231        };
232        format!("Story {}/{}", current, self.total)
233    }
234
235    /// Alias for `as_fraction()` for clarity when the story context is explicit.
236    pub fn as_story_fraction(&self) -> String {
237        self.as_fraction()
238    }
239
240    /// Format progress as a simple fraction (e.g., "2/5").
241    pub fn as_simple_fraction(&self) -> String {
242        format!("{}/{}", self.completed, self.total)
243    }
244
245    /// Format progress as a percentage (e.g., "40%").
246    pub fn as_percentage(&self) -> String {
247        if self.total == 0 {
248            return "0%".to_string();
249        }
250        let pct = (self.completed * 100) / self.total;
251        format!("{}%", pct)
252    }
253}
254
255/// Data collected from a single project for display.
256#[derive(Debug, Clone)]
257pub struct ProjectData {
258    /// Project metadata from the tree.
259    pub info: ProjectTreeInfo,
260    /// The active run state (if any).
261    pub active_run: Option<RunState>,
262    /// Progress through the spec (loaded from spec file).
263    pub progress: Option<RunProgress>,
264    /// Error message if state file is corrupted or unreadable.
265    pub load_error: Option<String>,
266}
267
268/// Data for a single session in the Active Runs view.
269///
270/// This struct represents one running session, which can be from
271/// the main repo or a worktree. Multiple sessions can belong to
272/// the same project (when using worktree mode).
273#[derive(Debug, Clone)]
274pub struct SessionData {
275    /// Project name (e.g., "autom8").
276    pub project_name: String,
277    /// Session metadata (includes session_id, worktree_path, branch).
278    pub metadata: SessionMetadata,
279    /// The active run state for this session.
280    pub run: Option<RunState>,
281    /// Progress through the spec (loaded from spec file).
282    pub progress: Option<RunProgress>,
283    /// Error message if state file is corrupted or unreadable.
284    pub load_error: Option<String>,
285    /// Whether this is the main repo session (vs. a worktree).
286    pub is_main_session: bool,
287    /// Whether this session is stale (worktree was deleted).
288    pub is_stale: bool,
289    /// Live output state for streaming Claude output (from live.json).
290    pub live_output: Option<LiveState>,
291    /// Cached user stories from the spec (to avoid file I/O on every render frame).
292    /// This is populated during `load_sessions()` and should be used by `load_story_items()`.
293    pub cached_user_stories: Option<Vec<UserStory>>,
294}
295
296impl SessionData {
297    /// Format the display title for this session.
298    /// Returns "project-name (main)" or "project-name (abc12345)".
299    pub fn display_title(&self) -> String {
300        if self.is_main_session {
301            format!("{} (main)", self.project_name)
302        } else {
303            format!("{} ({})", self.project_name, &self.metadata.session_id)
304        }
305    }
306
307    /// Check if this session has a fresh heartbeat (run is actively progressing).
308    ///
309    /// A session is considered "alive" if:
310    /// - It has live output data AND
311    /// - The heartbeat is recent (< 10 seconds old)
312    ///
313    /// This is the authoritative check for whether a run is actively progressing.
314    /// The GUI/TUI should use this to determine if a run is truly active,
315    /// rather than just checking `is_running` from metadata.
316    pub fn has_fresh_heartbeat(&self) -> bool {
317        self.live_output
318            .as_ref()
319            .map(|live| live.is_heartbeat_fresh())
320            .unwrap_or(false)
321    }
322
323    /// Check if this session should be considered actively running.
324    ///
325    /// A session is actively running if:
326    /// - It's not stale (worktree exists) AND
327    /// - It's marked as running AND
328    /// - It either has a fresh heartbeat OR there's no live data yet (run just started)
329    ///
330    /// This provides a lenient check that accounts for runs that just started
331    /// and haven't written live.json yet.
332    pub fn is_actively_running(&self) -> bool {
333        if self.is_stale || !self.metadata.is_running {
334            return false;
335        }
336
337        // If we have live output, check the heartbeat
338        // If no live output yet, trust the is_running flag (run may have just started)
339        self.live_output
340            .as_ref()
341            .map(|live| live.is_heartbeat_fresh())
342            .unwrap_or(true) // Trust is_running if no live data yet
343    }
344
345    /// Check if this session appears to be stuck (marked as running but heartbeat is stale).
346    ///
347    /// This helps identify crashed or stuck runs that need user intervention.
348    /// Returns true if:
349    /// - Session is marked as running AND
350    /// - Live output exists AND
351    /// - Heartbeat is stale (> 10 seconds old)
352    pub fn appears_stuck(&self) -> bool {
353        if !self.metadata.is_running || self.is_stale {
354            return false;
355        }
356
357        // If we have live output and heartbeat is stale, session appears stuck
358        self.live_output
359            .as_ref()
360            .map(|live| !live.is_heartbeat_fresh())
361            .unwrap_or(false) // No live output = can't determine stuck state
362    }
363
364    /// Get a truncated worktree path for display (last 2 components).
365    pub fn truncated_worktree_path(&self) -> String {
366        let path = &self.metadata.worktree_path;
367        let components: Vec<_> = path.components().collect();
368        if components.len() <= 2 {
369            path.display().to_string()
370        } else {
371            let last_two: PathBuf = components[components.len() - 2..].iter().collect();
372            format!(".../{}", last_two.display())
373        }
374    }
375}
376
377/// Data for a single entry in the run history panel.
378///
379/// Represents an archived run for a project, displayed in the history view.
380/// This is the canonical definition used by both GUI and TUI.
381#[derive(Debug, Clone)]
382pub struct RunHistoryEntry {
383    /// The project this run belongs to (used by TUI for grouping).
384    pub project_name: String,
385    /// The run ID.
386    pub run_id: String,
387    /// When the run started.
388    pub started_at: chrono::DateTime<chrono::Utc>,
389    /// When the run finished (if completed).
390    pub finished_at: Option<chrono::DateTime<chrono::Utc>>,
391    /// The run status (completed/failed/running).
392    pub status: RunStatus,
393    /// Number of completed stories.
394    pub completed_stories: usize,
395    /// Total number of stories in the spec.
396    pub total_stories: usize,
397    /// Branch name for this run.
398    pub branch: String,
399}
400
401impl RunHistoryEntry {
402    /// Create a RunHistoryEntry from a RunState with explicit story counts.
403    ///
404    /// Use this constructor when you have the story counts already computed.
405    pub fn new(
406        project_name: String,
407        run: &RunState,
408        completed_stories: usize,
409        total_stories: usize,
410    ) -> Self {
411        Self {
412            project_name,
413            run_id: run.run_id.clone(),
414            started_at: run.started_at,
415            finished_at: run.finished_at,
416            status: run.status,
417            completed_stories,
418            total_stories,
419            branch: run.branch.clone(),
420        }
421    }
422
423    /// Create a RunHistoryEntry from a RunState, computing story counts from iterations.
424    ///
425    /// This method computes completed/total stories by analyzing the run's iterations.
426    /// Use `new()` if you already have the story counts.
427    pub fn from_run_state(project_name: String, run: &RunState) -> Self {
428        // Count completed stories by looking at iterations with status Success
429        let completed_stories = run
430            .iterations
431            .iter()
432            .filter(|i| i.status == IterationStatus::Success)
433            .map(|i| &i.story_id)
434            .collect::<HashSet<_>>()
435            .len();
436
437        // Total stories is harder to determine from archived state
438        // Use the iteration count as a proxy (each story should have at least one iteration)
439        let story_ids: HashSet<_> = run.iterations.iter().map(|i| &i.story_id).collect();
440        let total_stories = story_ids.len().max(1);
441
442        Self {
443            project_name,
444            run_id: run.run_id.clone(),
445            started_at: run.started_at,
446            finished_at: run.finished_at,
447            status: run.status,
448            completed_stories,
449            total_stories,
450            branch: run.branch.clone(),
451        }
452    }
453
454    /// Format the story count as "X/Y stories".
455    pub fn story_count_text(&self) -> String {
456        format!("{}/{} stories", self.completed_stories, self.total_stories)
457    }
458
459    /// Format the run status as a display string.
460    pub fn status_text(&self) -> &'static str {
461        match self.status {
462            RunStatus::Completed => "Completed",
463            RunStatus::Failed => "Failed",
464            RunStatus::Running => "Running",
465            RunStatus::Interrupted => "Interrupted",
466        }
467    }
468}
469
470// ============================================================================
471// Shared Data Loading
472// ============================================================================
473
474/// Result of loading UI data from disk.
475///
476/// This struct contains all the data needed to populate the UI views,
477/// including projects, sessions, run history, and status flags.
478#[derive(Debug, Clone, Default)]
479pub struct UiData {
480    /// List of projects with their active run state.
481    pub projects: Vec<ProjectData>,
482    /// List of active sessions across all projects.
483    pub sessions: Vec<SessionData>,
484    /// Whether there are any active runs.
485    pub has_active_runs: bool,
486}
487
488/// Options for loading run history.
489#[derive(Debug, Clone, Default)]
490pub struct RunHistoryOptions {
491    /// Filter to a specific project (overrides project_filter from UiData load).
492    pub project_filter: Option<String>,
493    /// Maximum number of entries to return.
494    pub max_entries: Option<usize>,
495}
496
497/// Result of loading run history.
498#[derive(Debug, Clone, Default)]
499pub struct RunHistoryData {
500    /// List of run history entries.
501    pub entries: Vec<RunHistoryEntry>,
502    /// Full RunState objects for detail views (keyed by run_id).
503    /// Only populated when `include_full_state` is true.
504    pub run_states: std::collections::HashMap<String, RunState>,
505}
506
507/// Load UI data from disk.
508///
509/// This function consolidates the data loading logic used by both GUI and TUI.
510/// It loads the project tree, filters by project name if specified, loads
511/// active run states and session information.
512///
513/// # Arguments
514/// * `project_filter` - Optional project name to filter results
515///
516/// # Returns
517/// * `Result<UiData>` - The loaded data or an error
518///
519/// # Error Handling
520/// This function returns `Result` and lets callers decide how to handle errors.
521/// For GUI (which swallows errors), call `.unwrap_or_default()`.
522/// For TUI (which propagates errors), use the `?` operator.
523pub fn load_ui_data(project_filter: Option<&str>) -> Result<UiData> {
524    // Load sessions first - this is critical for Active Runs detection
525    // and doesn't depend on git or StateManager
526    let sessions = load_sessions(project_filter);
527
528    // Determine if there are active runs
529    let has_active_runs = !sessions.is_empty();
530
531    // Load projects for the Projects view (may fail if git issues, but that's ok)
532    // This uses StateManager which can spawn git subprocesses
533    let projects = match list_projects_tree() {
534        Ok(tree_infos) => {
535            let filtered: Vec<_> = if let Some(filter) = project_filter {
536                tree_infos
537                    .into_iter()
538                    .filter(|p| p.name == filter)
539                    .collect()
540            } else {
541                tree_infos
542            };
543            filtered.iter().map(load_project_data).collect()
544        }
545        Err(_) => {
546            // If project loading fails (e.g., git subprocess issues),
547            // return empty projects but still return sessions
548            Vec::new()
549        }
550    };
551
552    Ok(UiData {
553        projects,
554        sessions,
555        has_active_runs,
556    })
557}
558
559/// Load project data for a single project.
560fn load_project_data(info: &ProjectTreeInfo) -> ProjectData {
561    let (active_run, load_error) = if info.has_active_run {
562        match StateManager::for_project(&info.name) {
563            Ok(sm) => match sm.load_current() {
564                Ok(run) => (run, None),
565                Err(e) => (None, Some(format!("Corrupted state: {}", e))),
566            },
567            Err(e) => (None, Some(format!("State error: {}", e))),
568        }
569    } else {
570        (None, None)
571    };
572
573    // Load spec to get progress information
574    let progress = active_run.as_ref().and_then(|run| {
575        Spec::load(&run.spec_json_path)
576            .ok()
577            .map(|spec| RunProgress {
578                completed: spec.completed_count(),
579                total: spec.total_count(),
580            })
581    });
582
583    ProjectData {
584        info: info.clone(),
585        active_run,
586        progress,
587        load_error,
588    }
589}
590
591/// Load sessions for the Active Runs view.
592///
593/// Directly reads session metadata files from disk without going through
594/// StateManager, avoiding git subprocess spawning and other overhead.
595/// This makes session detection reliable regardless of where the UI runs from.
596fn load_sessions(project_filter: Option<&str>) -> Vec<SessionData> {
597    let mut sessions: Vec<SessionData> = Vec::new();
598
599    // Get the base config directory (~/.config/autom8/)
600    let base_dir = match crate::config::config_dir() {
601        Ok(dir) => dir,
602        Err(_) => return sessions,
603    };
604
605    if !base_dir.exists() {
606        return sessions;
607    }
608
609    // List all project directories
610    let project_dirs = match std::fs::read_dir(&base_dir) {
611        Ok(entries) => entries,
612        Err(_) => return sessions,
613    };
614
615    for entry in project_dirs.filter_map(|e| e.ok()) {
616        let project_path = entry.path();
617        if !project_path.is_dir() {
618            continue;
619        }
620
621        let project_name = match project_path.file_name().and_then(|n| n.to_str()) {
622            Some(name) => name.to_string(),
623            None => continue,
624        };
625
626        // Apply project filter if specified
627        if let Some(filter) = project_filter {
628            if project_name != filter {
629                continue;
630            }
631        }
632
633        // Look for sessions directory
634        let sessions_dir = project_path.join("sessions");
635        if !sessions_dir.exists() {
636            continue;
637        }
638
639        // List all session directories
640        let session_dirs = match std::fs::read_dir(&sessions_dir) {
641            Ok(entries) => entries,
642            Err(_) => continue,
643        };
644
645        for session_entry in session_dirs.filter_map(|e| e.ok()) {
646            let session_path = session_entry.path();
647            if !session_path.is_dir() {
648                continue;
649            }
650
651            // Read metadata.json directly
652            let metadata_path = session_path.join("metadata.json");
653            let metadata: SessionMetadata = match std::fs::read_to_string(&metadata_path) {
654                Ok(content) => match serde_json::from_str(&content) {
655                    Ok(m) => m,
656                    Err(_) => continue, // Skip malformed metadata
657                },
658                Err(_) => continue, // Skip if can't read
659            };
660
661            // Skip non-running sessions
662            if !metadata.is_running {
663                continue;
664            }
665
666            // Check if worktree was deleted (stale session)
667            let is_stale = !metadata.worktree_path.exists();
668            let is_main_session = metadata.session_id == MAIN_SESSION_ID;
669
670            // For stale sessions, add with error and skip state loading
671            if is_stale {
672                sessions.push(SessionData {
673                    project_name: project_name.clone(),
674                    metadata,
675                    run: None,
676                    progress: None,
677                    load_error: Some("Worktree has been deleted".to_string()),
678                    is_main_session,
679                    is_stale: true,
680                    live_output: None,
681                    cached_user_stories: None,
682                });
683                continue;
684            }
685
686            // Read state.json directly
687            let state_path = session_path.join("state.json");
688            let (run, load_error): (Option<RunState>, Option<String>) =
689                match std::fs::read_to_string(&state_path) {
690                    Ok(content) => match serde_json::from_str(&content) {
691                        Ok(state) => (Some(state), None),
692                        Err(e) => (None, Some(format!("Corrupted state: {}", e))),
693                    },
694                    Err(_) => (None, Some("State file not found".to_string())),
695                };
696
697            // Read live.json directly (optional, for output display)
698            let live_path = session_path.join("live.json");
699            let live_output: Option<LiveState> = std::fs::read_to_string(&live_path)
700                .ok()
701                .and_then(|content| serde_json::from_str(&content).ok());
702
703            // Load spec to get progress information and cache user stories
704            let (progress, cached_user_stories) = run
705                .as_ref()
706                .and_then(|r| Spec::load(&r.spec_json_path).ok())
707                .map(|spec| {
708                    let progress = RunProgress {
709                        completed: spec.completed_count(),
710                        total: spec.total_count(),
711                    };
712                    (Some(progress), Some(spec.user_stories))
713                })
714                .unwrap_or((None, None));
715
716            sessions.push(SessionData {
717                project_name: project_name.clone(),
718                metadata,
719                run,
720                progress,
721                load_error,
722                is_main_session,
723                is_stale: false,
724                live_output,
725                cached_user_stories,
726            });
727        }
728    }
729
730    // Sort sessions by last_active_at descending
731    sessions.sort_by(|a, b| b.metadata.last_active_at.cmp(&a.metadata.last_active_at));
732
733    sessions
734}
735
736/// Load a single session by project name and session ID.
737///
738/// Unlike `load_sessions`, this does NOT filter by `is_running`, so it can
739/// retrieve sessions that have completed (where `is_running` became false).
740/// This is useful for updating the GUI's `seen_sessions` cache when a run
741/// completes and disappears from the running sessions list.
742///
743/// # Arguments
744/// * `project_name` - The project to look in
745/// * `session_id` - The session ID to load
746///
747/// # Returns
748/// * `Option<SessionData>` - The session data if found, None otherwise
749pub fn load_session_by_id(project_name: &str, session_id: &str) -> Option<SessionData> {
750    // Get the base config directory (~/.config/autom8/)
751    let base_dir = crate::config::config_dir().ok()?;
752    let session_path = base_dir
753        .join(project_name)
754        .join("sessions")
755        .join(session_id);
756
757    if !session_path.is_dir() {
758        return None;
759    }
760
761    // Read metadata.json directly
762    let metadata_path = session_path.join("metadata.json");
763    let metadata: SessionMetadata = std::fs::read_to_string(&metadata_path)
764        .ok()
765        .and_then(|content| serde_json::from_str(&content).ok())?;
766
767    // Check if worktree was deleted (stale session)
768    let is_stale = !metadata.worktree_path.exists();
769    let is_main_session = metadata.session_id == MAIN_SESSION_ID;
770
771    // Read state.json directly
772    let state_path = session_path.join("state.json");
773    let (run, load_error): (Option<RunState>, Option<String>) =
774        match std::fs::read_to_string(&state_path) {
775            Ok(content) => match serde_json::from_str(&content) {
776                Ok(state) => (Some(state), None),
777                Err(e) => (None, Some(format!("Corrupted state: {}", e))),
778            },
779            Err(_) => (None, Some("State file not found".to_string())),
780        };
781
782    // Read live.json directly (optional, for output display)
783    let live_path = session_path.join("live.json");
784    let live_output: Option<LiveState> = std::fs::read_to_string(&live_path)
785        .ok()
786        .and_then(|content| serde_json::from_str(&content).ok());
787
788    // Load spec to get progress information and cache user stories
789    let (progress, cached_user_stories) = run
790        .as_ref()
791        .and_then(|r| Spec::load(&r.spec_json_path).ok())
792        .map(|spec| {
793            let progress = RunProgress {
794                completed: spec.completed_count(),
795                total: spec.total_count(),
796            };
797            (Some(progress), Some(spec.user_stories))
798        })
799        .unwrap_or((None, None));
800
801    Some(SessionData {
802        project_name: project_name.to_string(),
803        metadata,
804        run,
805        progress,
806        load_error,
807        is_main_session,
808        is_stale,
809        live_output,
810        cached_user_stories,
811    })
812}
813
814/// Load an archived run by run_id from a project's runs directory.
815///
816/// This is useful for retrieving the final state of a run after it completes
817/// and the session files have been cleaned up.
818///
819/// # Arguments
820/// * `project_name` - The project to look in
821/// * `run_id` - The run ID to find
822///
823/// # Returns
824/// * `Option<RunState>` - The archived run state if found
825pub fn load_archived_run(project_name: &str, run_id: &str) -> Option<RunState> {
826    let sm = StateManager::for_project(project_name).ok()?;
827    let archived = sm.list_archived().ok()?;
828    archived.into_iter().find(|r| r.run_id == run_id)
829}
830
831/// Load run history for display.
832///
833/// This function loads archived runs and converts them to RunHistoryEntry format.
834/// Optionally populates a cache of full RunState objects for detail views.
835///
836/// # Arguments
837/// * `projects` - List of projects to load history from
838/// * `options` - Options controlling filtering and limits
839/// * `include_full_state` - Whether to include full RunState objects in the result
840///
841/// # Returns
842/// * `Result<RunHistoryData>` - The loaded history data
843pub fn load_run_history(
844    projects: &[ProjectData],
845    options: &RunHistoryOptions,
846    include_full_state: bool,
847) -> Result<RunHistoryData> {
848    let mut history: Vec<RunHistoryEntry> = Vec::new();
849    let mut run_states: std::collections::HashMap<String, RunState> =
850        std::collections::HashMap::new();
851
852    // Determine which projects to load history from
853    let project_names: Vec<String> = if let Some(ref filter) = options.project_filter {
854        vec![filter.clone()]
855    } else {
856        projects.iter().map(|p| p.info.name.clone()).collect()
857    };
858
859    // Load archived runs from each project
860    for project_name in project_names {
861        if let Ok(sm) = StateManager::for_project(&project_name) {
862            if let Ok(archived) = sm.list_archived() {
863                for run in archived {
864                    // Try to load the spec to get story counts
865                    let (completed, total) = Spec::load(&run.spec_json_path)
866                        .map(|spec| (spec.completed_count(), spec.total_count()))
867                        .unwrap_or_else(|_| {
868                            // Fallback: count from iterations
869                            let completed = run
870                                .iterations
871                                .iter()
872                                .filter(|i| i.status == IterationStatus::Success)
873                                .count();
874                            (completed, run.iterations.len().max(completed))
875                        });
876
877                    // Cache the full run state if requested
878                    if include_full_state {
879                        run_states.insert(run.run_id.clone(), run.clone());
880                    }
881
882                    history.push(RunHistoryEntry::new(
883                        project_name.clone(),
884                        &run,
885                        completed,
886                        total,
887                    ));
888                }
889            }
890        }
891    }
892
893    // Sort by date, most recent first
894    history.sort_by(|a, b| b.started_at.cmp(&a.started_at));
895
896    // Apply limit if specified
897    if let Some(max) = options.max_entries {
898        history.truncate(max);
899    }
900
901    Ok(RunHistoryData {
902        entries: history,
903        run_states,
904    })
905}
906
907/// Load run history for a single project (simplified API for GUI).
908///
909/// This is a convenience wrapper around `load_run_history` for cases where
910/// you want to load history for a single project and don't need full state.
911///
912/// # Arguments
913/// * `project_name` - The project to load history from
914///
915/// # Returns
916/// * `Result<Vec<RunHistoryEntry>>` - The loaded history entries
917pub fn load_project_run_history(project_name: &str) -> Result<Vec<RunHistoryEntry>> {
918    let mut history: Vec<RunHistoryEntry> = Vec::new();
919
920    let sm = StateManager::for_project(project_name)?;
921    let archived = sm.list_archived()?;
922
923    for run in archived {
924        history.push(RunHistoryEntry::from_run_state(
925            project_name.to_string(),
926            &run,
927        ));
928    }
929
930    // Sort with running sessions at top, then by date descending
931    history.sort_by(|a, b| {
932        // First priority: running status at top
933        let a_running = matches!(a.status, RunStatus::Running);
934        let b_running = matches!(b.status, RunStatus::Running);
935
936        match (a_running, b_running) {
937            (true, false) => std::cmp::Ordering::Less,
938            (false, true) => std::cmp::Ordering::Greater,
939            // Both same category: sort by started_at descending (newest first)
940            _ => b.started_at.cmp(&a.started_at),
941        }
942    });
943
944    Ok(history)
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950    use chrono::Utc;
951    use std::path::PathBuf;
952
953    // =========================================================================
954    // RunProgress Tests
955    // =========================================================================
956
957    #[test]
958    fn test_run_progress_formatting() {
959        // Fraction display
960        assert_eq!(RunProgress::new(1, 5).as_fraction(), "Story 2/5");
961        assert_eq!(RunProgress::new(0, 5).as_fraction(), "Story 1/5");
962        assert_eq!(RunProgress::new(5, 5).as_fraction(), "Story 5/5"); // Completed
963        assert_eq!(RunProgress::new(0, 0).as_fraction(), "Story 0/0"); // Empty
964
965        // Percentage display
966        assert_eq!(RunProgress::new(2, 5).as_percentage(), "40%");
967        assert_eq!(RunProgress::new(5, 5).as_percentage(), "100%");
968        assert_eq!(RunProgress::new(0, 0).as_percentage(), "0%");
969
970        // Numeric fraction
971        assert!((RunProgress::new(2, 5).fraction() - 0.4).abs() < 0.001);
972        assert_eq!(RunProgress::new(0, 0).fraction(), 0.0);
973
974        // Simple fraction
975        assert_eq!(RunProgress::new(2, 5).as_simple_fraction(), "2/5");
976    }
977
978    // =========================================================================
979    // SessionData Tests
980    // =========================================================================
981
982    fn make_test_session(is_main: bool, is_running: bool, is_stale: bool) -> SessionData {
983        SessionData {
984            project_name: "test-project".to_string(),
985            metadata: SessionMetadata {
986                session_id: if is_main { "main" } else { "abc123" }.to_string(),
987                worktree_path: PathBuf::from("/path/to/repo"),
988                branch_name: "test-branch".to_string(),
989                created_at: Utc::now(),
990                last_active_at: Utc::now(),
991                is_running,
992                spec_json_path: None,
993            },
994            run: None,
995            progress: None,
996            load_error: None,
997            is_main_session: is_main,
998            is_stale,
999            live_output: None,
1000            cached_user_stories: None,
1001        }
1002    }
1003
1004    #[test]
1005    fn test_session_data_display_and_paths() {
1006        let main = make_test_session(true, false, false);
1007        assert_eq!(main.display_title(), "test-project (main)");
1008
1009        let worktree = make_test_session(false, false, false);
1010        assert_eq!(worktree.display_title(), "test-project (abc123)");
1011
1012        // Truncated path (short)
1013        let mut short_path = make_test_session(false, false, false);
1014        short_path.metadata.worktree_path = PathBuf::from("repo");
1015        assert_eq!(short_path.truncated_worktree_path(), "repo");
1016
1017        // Truncated path (long)
1018        let mut long_path = make_test_session(false, false, false);
1019        long_path.metadata.worktree_path = PathBuf::from("/home/user/projects/repo");
1020        assert_eq!(long_path.truncated_worktree_path(), ".../projects/repo");
1021    }
1022
1023    #[test]
1024    fn test_session_heartbeat_and_status() {
1025        // No live output = no fresh heartbeat
1026        let no_live = make_test_session(true, true, false);
1027        assert!(!no_live.has_fresh_heartbeat());
1028        assert!(no_live.is_actively_running()); // Trust is_running
1029
1030        // Fresh live output
1031        let mut fresh = make_test_session(true, true, false);
1032        fresh.live_output = Some(LiveState::new(MachineState::RunningClaude));
1033        assert!(fresh.has_fresh_heartbeat());
1034        assert!(!fresh.appears_stuck());
1035
1036        // Stale session never actively running
1037        let stale = make_test_session(false, true, true);
1038        assert!(!stale.is_actively_running());
1039
1040        // Stale heartbeat = appears stuck
1041        let mut stuck = make_test_session(true, true, false);
1042        let mut stale_live = LiveState::new(MachineState::RunningClaude);
1043        stale_live.last_heartbeat = Utc::now() - chrono::Duration::seconds(65);
1044        stuck.live_output = Some(stale_live);
1045        assert!(stuck.appears_stuck());
1046
1047        // Not running = not stuck
1048        let not_running = make_test_session(true, false, false);
1049        assert!(!not_running.appears_stuck());
1050    }
1051
1052    // =========================================================================
1053    // Status Mapping Tests
1054    // =========================================================================
1055
1056    #[test]
1057    fn test_status_from_machine_state() {
1058        // Setup phases
1059        assert_eq!(
1060            Status::from_machine_state(MachineState::Initializing),
1061            Status::Setup
1062        );
1063        assert_eq!(
1064            Status::from_machine_state(MachineState::PickingStory),
1065            Status::Setup
1066        );
1067        assert_eq!(
1068            Status::from_machine_state(MachineState::LoadingSpec),
1069            Status::Setup
1070        );
1071
1072        // Work phases
1073        assert_eq!(
1074            Status::from_machine_state(MachineState::RunningClaude),
1075            Status::Running
1076        );
1077        assert_eq!(
1078            Status::from_machine_state(MachineState::Reviewing),
1079            Status::Reviewing
1080        );
1081        assert_eq!(
1082            Status::from_machine_state(MachineState::Correcting),
1083            Status::Correcting
1084        );
1085
1086        // Success phases
1087        assert_eq!(
1088            Status::from_machine_state(MachineState::Committing),
1089            Status::Success
1090        );
1091        assert_eq!(
1092            Status::from_machine_state(MachineState::Completed),
1093            Status::Success
1094        );
1095
1096        // Terminal
1097        assert_eq!(
1098            Status::from_machine_state(MachineState::Failed),
1099            Status::Error
1100        );
1101        assert_eq!(Status::from_machine_state(MachineState::Idle), Status::Idle);
1102    }
1103
1104    // =========================================================================
1105    // Duration Formatting Tests
1106    // =========================================================================
1107
1108    #[test]
1109    fn test_duration_formatting() {
1110        assert_eq!(format_duration_secs(30), "30s");
1111        assert_eq!(format_duration_secs(125), "2m 5s");
1112        assert_eq!(format_duration_secs(3600), "1h 0m");
1113        assert_eq!(format_duration_secs(7265), "2h 1m");
1114    }
1115
1116    #[test]
1117    fn test_format_run_duration_with_finished_at() {
1118        let started = Utc::now() - chrono::Duration::seconds(300);
1119        let finished = started + chrono::Duration::seconds(125);
1120        // With finished_at: should compute fixed duration (125s = 2m 5s)
1121        assert_eq!(format_run_duration(started, Some(finished)), "2m 5s");
1122    }
1123
1124    #[test]
1125    fn test_format_run_duration_without_finished_at() {
1126        // Without finished_at: should use live duration (now - started_at)
1127        let started = Utc::now() - chrono::Duration::seconds(5);
1128        let result = format_run_duration(started, None);
1129        // Should be a small number of seconds (between 4s and 6s given timing)
1130        assert!(
1131            result.ends_with('s'),
1132            "Expected seconds format, got: {}",
1133            result
1134        );
1135    }
1136
1137    #[test]
1138    fn test_relative_time_formatting() {
1139        assert_eq!(format_relative_time_secs(30), "just now");
1140        assert_eq!(format_relative_time_secs(300), "5m ago");
1141        assert_eq!(format_relative_time_secs(3600), "1h ago");
1142        assert_eq!(format_relative_time_secs(86400), "1d ago");
1143    }
1144
1145    // =========================================================================
1146    // Run History Entry Tests
1147    // =========================================================================
1148
1149    #[test]
1150    fn test_run_history_entry() {
1151        let entry = RunHistoryEntry {
1152            project_name: "test-project".to_string(),
1153            run_id: "test-run".to_string(),
1154            started_at: Utc::now(),
1155            finished_at: None,
1156            status: RunStatus::Completed,
1157            completed_stories: 3,
1158            total_stories: 5,
1159            branch: "feature/test".to_string(),
1160        };
1161        assert_eq!(entry.status_text(), "Completed");
1162        assert_eq!(entry.story_count_text(), "3/5 stories");
1163    }
1164
1165    fn make_history_entry(run_id: &str, status: RunStatus, age_secs: i64) -> RunHistoryEntry {
1166        RunHistoryEntry {
1167            project_name: "test".to_string(),
1168            run_id: run_id.to_string(),
1169            started_at: Utc::now() - chrono::Duration::seconds(age_secs),
1170            finished_at: None,
1171            status,
1172            completed_stories: 0,
1173            total_stories: 5,
1174            branch: "test".to_string(),
1175        }
1176    }
1177
1178    #[test]
1179    fn test_run_history_sorting() {
1180        let mut history = vec![
1181            make_history_entry("completed-old", RunStatus::Completed, 60),
1182            make_history_entry("running", RunStatus::Running, 3600),
1183            make_history_entry("completed-new", RunStatus::Completed, 0),
1184        ];
1185
1186        history.sort_by(|a, b| {
1187            let a_running = matches!(a.status, RunStatus::Running);
1188            let b_running = matches!(b.status, RunStatus::Running);
1189            match (a_running, b_running) {
1190                (true, false) => std::cmp::Ordering::Less,
1191                (false, true) => std::cmp::Ordering::Greater,
1192                _ => b.started_at.cmp(&a.started_at),
1193            }
1194        });
1195
1196        // Running first, then by date
1197        assert_eq!(history[0].run_id, "running");
1198        assert_eq!(history[1].run_id, "completed-new");
1199        assert_eq!(history[2].run_id, "completed-old");
1200    }
1201}