Skip to main content

ag_session/
project.rs

1use std::cmp::Reverse;
2use std::path::{Path, PathBuf};
3
4/// Persisted project metadata used for multi-project management.
5#[derive(Clone, Debug, Eq, PartialEq)]
6pub struct Project {
7    /// Creation timestamp in Unix seconds.
8    pub created_at: i64,
9    /// Optional user-defined display name.
10    pub display_name: Option<String>,
11    /// Last observed branch for the project checkout.
12    pub git_branch: Option<String>,
13    /// Stable database identifier.
14    pub id: i64,
15    /// Whether the project is pinned ahead of other projects.
16    pub is_favorite: bool,
17    /// Most recent project-open timestamp in Unix seconds.
18    pub last_opened_at: Option<i64>,
19    /// Absolute path to the project checkout.
20    pub path: PathBuf,
21    /// Last metadata update timestamp in Unix seconds.
22    pub updated_at: i64,
23}
24
25impl Project {
26    /// Returns the preferred project label for UI rendering.
27    #[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/// Aggregated project snapshot for list and switcher views.
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct ProjectListItem {
42    /// Number of sessions still in an active lifecycle state.
43    pub active_session_count: u32,
44    /// Total input tokens accumulated by sessions in this project.
45    pub input_tokens: u64,
46    /// Most recent session update timestamp for this project.
47    pub last_session_updated_at: Option<i64>,
48    /// Total output tokens accumulated by sessions in this project.
49    pub output_tokens: u64,
50    /// Persisted project metadata for display and selection.
51    pub project: Project,
52    /// Total number of sessions belonging to this project.
53    pub session_count: u32,
54}
55
56/// Returns indices into `project_items` ordered most-recently-opened first.
57///
58/// Projects that were never opened sort after all opened projects; ties are
59/// broken by display label so the switcher order stays deterministic. Callers
60/// cache the returned order alongside the rows it was derived from and rebuild
61/// it only when the rows change, keeping the sort off the render path.
62#[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/// Returns the project rows named by `project_order`, skipping stale indices.
75#[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/// Derives a readable project name from its filesystem path.
87#[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        // Arrange
102        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        // Act
114        let label = project.display_label();
115
116        // Assert
117        assert_eq!(label, "Agentty");
118    }
119
120    #[test]
121    fn test_mru_project_order_orders_by_last_opened_descending() {
122        // Arrange
123        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        // Act
130        let project_order = mru_project_order(&project_items);
131
132        // Assert
133        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        // Arrange
140        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        // Act
147        let project_order = mru_project_order(&project_items);
148
149        // Assert
150        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        // Arrange
157        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        // Act
163        let ordered_items = ordered_project_items(&project_items, &[1, 7, 0]);
164
165        // Assert
166        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    /// Maps an MRU order into the project identifiers it selects.
171    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    /// Builds one project list row with the provided identity and MRU stamp.
179    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        // Arrange
206        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        // Act
218        let label = project.display_label();
219
220        // Assert
221        assert_eq!(label, "agentty");
222    }
223}