1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Status {
40 Setup,
42 Running,
44 Reviewing,
46 Correcting,
48 Success,
50 Warning,
52 Error,
54 Idle,
56}
57
58impl Status {
59 pub fn from_machine_state(state: MachineState) -> Self {
70 match state {
71 MachineState::Initializing
73 | MachineState::PickingStory
74 | MachineState::LoadingSpec
75 | MachineState::GeneratingSpec => Status::Setup,
76
77 MachineState::RunningClaude => Status::Running,
79
80 MachineState::Reviewing => Status::Reviewing,
82
83 MachineState::Correcting => Status::Correcting,
85
86 MachineState::Committing | MachineState::CreatingPR | MachineState::Completed => {
88 Status::Success
89 }
90
91 MachineState::Failed => Status::Error,
93
94 MachineState::Idle => Status::Idle,
96 }
97 }
98}
99
100pub 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
121pub 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
132pub 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
145pub 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
164pub 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
175pub 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#[derive(Debug, Clone, Copy)]
201pub struct RunProgress {
202 pub completed: usize,
204 pub total: usize,
206}
207
208impl RunProgress {
209 pub fn new(completed: usize, total: usize) -> Self {
211 Self { completed, total }
212 }
213
214 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 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 pub fn as_story_fraction(&self) -> String {
237 self.as_fraction()
238 }
239
240 pub fn as_simple_fraction(&self) -> String {
242 format!("{}/{}", self.completed, self.total)
243 }
244
245 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#[derive(Debug, Clone)]
257pub struct ProjectData {
258 pub info: ProjectTreeInfo,
260 pub active_run: Option<RunState>,
262 pub progress: Option<RunProgress>,
264 pub load_error: Option<String>,
266}
267
268#[derive(Debug, Clone)]
274pub struct SessionData {
275 pub project_name: String,
277 pub metadata: SessionMetadata,
279 pub run: Option<RunState>,
281 pub progress: Option<RunProgress>,
283 pub load_error: Option<String>,
285 pub is_main_session: bool,
287 pub is_stale: bool,
289 pub live_output: Option<LiveState>,
291 pub cached_user_stories: Option<Vec<UserStory>>,
294}
295
296impl SessionData {
297 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 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 pub fn is_actively_running(&self) -> bool {
333 if self.is_stale || !self.metadata.is_running {
334 return false;
335 }
336
337 self.live_output
340 .as_ref()
341 .map(|live| live.is_heartbeat_fresh())
342 .unwrap_or(true) }
344
345 pub fn appears_stuck(&self) -> bool {
353 if !self.metadata.is_running || self.is_stale {
354 return false;
355 }
356
357 self.live_output
359 .as_ref()
360 .map(|live| !live.is_heartbeat_fresh())
361 .unwrap_or(false) }
363
364 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#[derive(Debug, Clone)]
382pub struct RunHistoryEntry {
383 pub project_name: String,
385 pub run_id: String,
387 pub started_at: chrono::DateTime<chrono::Utc>,
389 pub finished_at: Option<chrono::DateTime<chrono::Utc>>,
391 pub status: RunStatus,
393 pub completed_stories: usize,
395 pub total_stories: usize,
397 pub branch: String,
399}
400
401impl RunHistoryEntry {
402 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 pub fn from_run_state(project_name: String, run: &RunState) -> Self {
428 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 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 pub fn story_count_text(&self) -> String {
456 format!("{}/{} stories", self.completed_stories, self.total_stories)
457 }
458
459 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#[derive(Debug, Clone, Default)]
479pub struct UiData {
480 pub projects: Vec<ProjectData>,
482 pub sessions: Vec<SessionData>,
484 pub has_active_runs: bool,
486}
487
488#[derive(Debug, Clone, Default)]
490pub struct RunHistoryOptions {
491 pub project_filter: Option<String>,
493 pub max_entries: Option<usize>,
495}
496
497#[derive(Debug, Clone, Default)]
499pub struct RunHistoryData {
500 pub entries: Vec<RunHistoryEntry>,
502 pub run_states: std::collections::HashMap<String, RunState>,
505}
506
507pub fn load_ui_data(project_filter: Option<&str>) -> Result<UiData> {
524 let sessions = load_sessions(project_filter);
527
528 let has_active_runs = !sessions.is_empty();
530
531 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 Vec::new()
549 }
550 };
551
552 Ok(UiData {
553 projects,
554 sessions,
555 has_active_runs,
556 })
557}
558
559fn 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 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
591fn load_sessions(project_filter: Option<&str>) -> Vec<SessionData> {
597 let mut sessions: Vec<SessionData> = Vec::new();
598
599 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 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 if let Some(filter) = project_filter {
628 if project_name != filter {
629 continue;
630 }
631 }
632
633 let sessions_dir = project_path.join("sessions");
635 if !sessions_dir.exists() {
636 continue;
637 }
638
639 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 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, },
658 Err(_) => continue, };
660
661 if !metadata.is_running {
663 continue;
664 }
665
666 let is_stale = !metadata.worktree_path.exists();
668 let is_main_session = metadata.session_id == MAIN_SESSION_ID;
669
670 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 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 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 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 sessions.sort_by(|a, b| b.metadata.last_active_at.cmp(&a.metadata.last_active_at));
732
733 sessions
734}
735
736pub fn load_session_by_id(project_name: &str, session_id: &str) -> Option<SessionData> {
750 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 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 let is_stale = !metadata.worktree_path.exists();
769 let is_main_session = metadata.session_id == MAIN_SESSION_ID;
770
771 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 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 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
814pub 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
831pub 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 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 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 let (completed, total) = Spec::load(&run.spec_json_path)
866 .map(|spec| (spec.completed_count(), spec.total_count()))
867 .unwrap_or_else(|_| {
868 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 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 history.sort_by(|a, b| b.started_at.cmp(&a.started_at));
895
896 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
907pub 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 history.sort_by(|a, b| {
932 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 _ => 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 #[test]
958 fn test_run_progress_formatting() {
959 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"); assert_eq!(RunProgress::new(0, 0).as_fraction(), "Story 0/0"); 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 assert!((RunProgress::new(2, 5).fraction() - 0.4).abs() < 0.001);
972 assert_eq!(RunProgress::new(0, 0).fraction(), 0.0);
973
974 assert_eq!(RunProgress::new(2, 5).as_simple_fraction(), "2/5");
976 }
977
978 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 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 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 let no_live = make_test_session(true, true, false);
1027 assert!(!no_live.has_fresh_heartbeat());
1028 assert!(no_live.is_actively_running()); 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 let stale = make_test_session(false, true, true);
1038 assert!(!stale.is_actively_running());
1039
1040 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 let not_running = make_test_session(true, false, false);
1049 assert!(!not_running.appears_stuck());
1050 }
1051
1052 #[test]
1057 fn test_status_from_machine_state() {
1058 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 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 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 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 #[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 assert_eq!(format_run_duration(started, Some(finished)), "2m 5s");
1122 }
1123
1124 #[test]
1125 fn test_format_run_duration_without_finished_at() {
1126 let started = Utc::now() - chrono::Duration::seconds(5);
1128 let result = format_run_duration(started, None);
1129 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 #[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 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}