//! Pure helpers behind the spawn form's project picker (#64): staleness, //! recency ordering, worktree grouping, and free-text filtering. Mirrors //! `web/src/projectList.ts` so both clients order and dim identically — the //! daemon reports the timestamps; what "stale" means is a client decision //! (#54). use chrono::{DateTime, TimeDelta, Utc}; use maestro_protocol::Project; /// Projects whose last activity is older than this are dimmed and sort after /// fresh ones — still reachable, visibly stale. pub const STALE_AFTER_DAYS: i64 = 7; /// True when a project has no usable activity timestamp, one in the future, /// or its last activity is older than [`STALE_AFTER_DAYS`] days. pub fn is_stale(project: &Project, now: DateTime) -> bool { match project.last_activity { None => true, Some(t) => { let age = now.signed_duration_since(t); age < TimeDelta::zero() || age > TimeDelta::days(STALE_AFTER_DAYS) } } } fn time_of(project: &Project) -> DateTime { project.last_activity.unwrap_or(DateTime::UNIX_EPOCH) } /// Fresh projects first, each block newest-activity-first; unknown or future /// timestamps sort as stalest. Ties fall back to name, then path, so the /// order is stable. pub fn sort_projects(mut projects: Vec, now: DateTime) -> Vec { projects.sort_by(|a, b| { let stale = is_stale(a, now).cmp(&is_stale(b, now)); stale .then_with(|| time_of(b).cmp(&time_of(a))) .then_with(|| a.name.cmp(&b.name)) .then_with(|| a.path.cmp(&b.path)) }); projects } /// Substring filter over project name and full path, case-insensitive. pub fn matches_filter(project: &Project, query: &str) -> bool { let q = query.trim().to_lowercase(); if q.is_empty() { return true; } project.name.to_lowercase().contains(&q) || project.path.to_string_lossy().to_lowercase().contains(&q) } /// One repository group: the main checkout followed by its linked worktrees. /// A group's position follows its freshest member, since a worktree worked on /// today should pull its (dormant) base repo up with it. #[derive(Debug)] pub struct ProjectGroup<'a> { pub main: &'a Project, pub worktrees: Vec<&'a Project>, } /// Group `sorted` projects by their base repository. A worktree nests under /// its main checkout even when the main sorts later; a main checkout missing /// from the list doesn't drop its worktrees. pub fn group_projects(sorted: &[Project]) -> Vec> { let mut members: Vec<(String, Vec<&Project>)> = Vec::new(); for p in sorted { let key = p .base_repo .as_ref() .unwrap_or(&p.path) .to_string_lossy() .into_owned(); match members.iter_mut().find(|(k, _)| *k == key) { Some((_, list)) => list.push(p), None => members.push((key, vec![p])), } } members .into_iter() .map(|(key, mut list)| { let main_pos = list .iter() .position(|p| p.path.to_string_lossy() == key && !p.is_worktree) .unwrap_or(0); let main = list.remove(main_pos); // Keep the daemon's newest-first order within the group. list.sort_by_key(|p| std::cmp::Reverse(time_of(p))); ProjectGroup { main, worktrees: list, } }) .collect() } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; fn days_ago(days: i64) -> DateTime { Utc::now() - TimeDelta::days(days) } fn project(name: &str, path: &str, last_activity: Option>) -> Project { Project { path: PathBuf::from(path), name: name.into(), is_worktree: false, base_repo: None, last_activity, last_command: None, } } fn worktree( name: &str, path: &str, base: &str, last_activity: Option>, ) -> Project { Project { is_worktree: true, base_repo: Some(PathBuf::from(base)), ..project(name, path, last_activity) } } #[test] fn unknown_and_future_activity_are_stale() { let now = Utc::now(); assert!(is_stale(&project("x", "/x", None), now)); assert!(is_stale( &project("x", "/x", Some(now + TimeDelta::hours(1))), now )); } #[test] fn activity_within_a_week_is_fresh_beyond_is_stale() { let now = Utc::now(); assert!(!is_stale(&project("x", "/x", Some(days_ago(6))), now)); assert!(is_stale(&project("x", "/x", Some(days_ago(8))), now)); } #[test] fn fresh_sorts_before_stale_then_newest_first() { let now = Utc::now(); let sorted = sort_projects( vec![ project("old-fresh", "/a", Some(days_ago(5))), project("stale", "/b", Some(days_ago(30))), project("newest", "/c", Some(days_ago(1))), project("unknown", "/d", None), ], now, ); let names: Vec<&str> = sorted.iter().map(|p| p.name.as_str()).collect(); assert_eq!(names, ["newest", "old-fresh", "stale", "unknown"]); } #[test] fn ties_break_by_name_then_path() { let now = Utc::now(); let t = Some(days_ago(2)); let sorted = sort_projects(vec![project("b", "/b", t), project("a", "/a", t)], now); let names: Vec<&str> = sorted.iter().map(|p| p.name.as_str()).collect(); assert_eq!(names, ["a", "b"]); } #[test] fn filter_matches_name_or_path_case_insensitively() { let p = project("Maestro", "/home/u/tea/Maestro", None); assert!(matches_filter(&p, "maes")); assert!(matches_filter(&p, " tea ")); assert!(!matches_filter(&p, "zzz")); assert!(matches_filter(&p, ""), "empty query matches everything"); } #[test] fn worktrees_nest_under_their_main_checkout() { let now = Utc::now(); let main = project("maestro", "/repos/maestro", Some(days_ago(10))); let wt = worktree("64-tui", "/repos/wt64", "/repos/maestro", Some(days_ago(1))); let other = project("other", "/repos/other", Some(days_ago(2))); let sorted = sort_projects(vec![main, wt, other], now); let groups = group_projects(&sorted); assert_eq!(groups.len(), 2); assert_eq!( groups[0].main.name, "maestro", "fresh worktree pulls its repo up" ); assert_eq!(groups[0].worktrees.len(), 1); assert_eq!(groups[0].worktrees[0].name, "64-tui"); assert_eq!(groups[1].main.name, "other"); } /// A main checkout absent from the scan must not drop its worktrees. #[test] fn worktrees_survive_a_missing_main_checkout() { let now = Utc::now(); let wt = worktree( "solo-wt", "/repos/solo-wt", "/repos/solo", Some(days_ago(1)), ); let sorted = sort_projects(vec![wt], now); let groups = group_projects(&sorted); assert_eq!(groups.len(), 1); assert_eq!(groups[0].main.name, "solo-wt"); assert!(groups[0].worktrees.is_empty()); } }