1use std::cmp::Reverse;
2use std::path::{Path, PathBuf};
3
4#[derive(Clone, Debug, Eq, PartialEq)]
6pub struct Project {
7 pub created_at: i64,
9 pub display_name: Option<String>,
11 pub git_branch: Option<String>,
13 pub id: i64,
15 pub is_favorite: bool,
17 pub last_opened_at: Option<i64>,
19 pub path: PathBuf,
21 pub updated_at: i64,
23}
24
25impl Project {
26 #[must_use]
28 pub fn display_label(&self) -> String {
29 if let Some(display_name) = self.display_name.as_deref()
30 && !display_name.trim().is_empty()
31 {
32 return display_name.to_string();
33 }
34
35 project_name_from_path(self.path.as_path())
36 }
37}
38
39#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct ProjectListItem {
42 pub active_session_count: u32,
44 pub input_tokens: u64,
46 pub last_session_updated_at: Option<i64>,
48 pub output_tokens: u64,
50 pub project: Project,
52 pub session_count: u32,
54}
55
56#[must_use]
63pub fn mru_project_order(project_items: &[ProjectListItem]) -> Vec<usize> {
64 let mut ordered_indices: Vec<usize> = (0..project_items.len()).collect();
65 ordered_indices.sort_by_cached_key(|&index| {
66 let project = &project_items[index].project;
67
68 (Reverse(project.last_opened_at), project.display_label())
69 });
70
71 ordered_indices
72}
73
74#[must_use]
76pub fn ordered_project_items<'a>(
77 project_items: &'a [ProjectListItem],
78 project_order: &[usize],
79) -> Vec<&'a ProjectListItem> {
80 project_order
81 .iter()
82 .filter_map(|&index| project_items.get(index))
83 .collect()
84}
85
86#[must_use]
88pub fn project_name_from_path(path: &Path) -> String {
89 path.file_name()
90 .and_then(|name| name.to_str())
91 .unwrap_or_default()
92 .to_string()
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 #[test]
100 fn test_display_label_prefers_display_name() {
101 let project = Project {
103 created_at: 0,
104 display_name: Some("Agentty".to_string()),
105 git_branch: Some("main".to_string()),
106 id: 1,
107 is_favorite: false,
108 last_opened_at: None,
109 path: PathBuf::from("/tmp/agentty"),
110 updated_at: 0,
111 };
112
113 let label = project.display_label();
115
116 assert_eq!(label, "Agentty");
118 }
119
120 #[test]
121 fn test_mru_project_order_orders_by_last_opened_descending() {
122 let project_items = vec![
124 project_list_item_fixture(1, "alpha", Some(10)),
125 project_list_item_fixture(2, "beta", Some(30)),
126 project_list_item_fixture(3, "gamma", Some(20)),
127 ];
128
129 let project_order = mru_project_order(&project_items);
131
132 let ordered_ids = ordered_project_ids(&project_items, &project_order);
134 assert_eq!(ordered_ids, vec![2, 3, 1]);
135 }
136
137 #[test]
138 fn test_mru_project_order_sorts_never_opened_projects_last_by_label() {
139 let project_items = vec![
141 project_list_item_fixture(1, "zeta", None),
142 project_list_item_fixture(2, "alpha", None),
143 project_list_item_fixture(3, "beta", Some(5)),
144 ];
145
146 let project_order = mru_project_order(&project_items);
148
149 let ordered_ids = ordered_project_ids(&project_items, &project_order);
151 assert_eq!(ordered_ids, vec![3, 2, 1]);
152 }
153
154 #[test]
155 fn test_ordered_project_items_skips_stale_indices() {
156 let project_items = vec![
158 project_list_item_fixture(1, "alpha", Some(10)),
159 project_list_item_fixture(2, "beta", Some(30)),
160 ];
161
162 let ordered_items = ordered_project_items(&project_items, &[1, 7, 0]);
164
165 let ordered_ids: Vec<i64> = ordered_items.iter().map(|item| item.project.id).collect();
167 assert_eq!(ordered_ids, vec![2, 1]);
168 }
169
170 fn ordered_project_ids(project_items: &[ProjectListItem], project_order: &[usize]) -> Vec<i64> {
172 ordered_project_items(project_items, project_order)
173 .iter()
174 .map(|project_item| project_item.project.id)
175 .collect()
176 }
177
178 fn project_list_item_fixture(
180 id: i64,
181 name: &str,
182 last_opened_at: Option<i64>,
183 ) -> ProjectListItem {
184 ProjectListItem {
185 active_session_count: 0,
186 input_tokens: 0,
187 last_session_updated_at: None,
188 output_tokens: 0,
189 project: Project {
190 created_at: 0,
191 display_name: Some(name.to_string()),
192 git_branch: Some("main".to_string()),
193 id,
194 is_favorite: false,
195 last_opened_at,
196 path: PathBuf::from(format!("/tmp/{name}")),
197 updated_at: 0,
198 },
199 session_count: 0,
200 }
201 }
202
203 #[test]
204 fn test_display_label_falls_back_to_path_folder_name() {
205 let project = Project {
207 created_at: 0,
208 display_name: None,
209 git_branch: Some("main".to_string()),
210 id: 1,
211 is_favorite: false,
212 last_opened_at: None,
213 path: PathBuf::from("/tmp/agentty"),
214 updated_at: 0,
215 };
216
217 let label = project.display_label();
219
220 assert_eq!(label, "agentty");
222 }
223}