Skip to main content

ag_store/
project.rs

1//! Project-scoped persistence adapters and query helpers.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use sqlx::SqlitePool;
7
8use crate::DbError;
9use crate::timestamp::TimestampSource;
10
11/// Row returned when loading a project from the `project` table.
12pub struct ProjectRow {
13    /// Creation timestamp in Unix seconds.
14    pub created_at: i64,
15    /// Optional user-defined display name.
16    pub display_name: Option<String>,
17    /// Last observed branch for the project checkout.
18    pub git_branch: Option<String>,
19    /// Stable database identifier.
20    pub id: i64,
21    /// Whether the project is pinned ahead of other projects.
22    pub is_favorite: bool,
23    /// Most recent project-open timestamp in Unix seconds.
24    pub last_opened_at: Option<i64>,
25    /// Persisted project checkout path.
26    pub path: String,
27    /// Last metadata update timestamp in Unix seconds.
28    pub updated_at: i64,
29}
30
31/// Row returned when loading one project with aggregated session statistics.
32pub struct ProjectListRow {
33    /// Number of sessions in an active lifecycle state.
34    pub active_session_count: i64,
35    /// Project creation timestamp in Unix seconds.
36    pub created_at: i64,
37    /// Optional user-defined display name.
38    pub display_name: Option<String>,
39    /// Last observed branch for the project checkout.
40    pub git_branch: Option<String>,
41    /// Stable project identifier.
42    pub id: i64,
43    /// Total input tokens accumulated by sessions in this project.
44    pub input_tokens: i64,
45    /// Whether the project is pinned ahead of other projects.
46    pub is_favorite: bool,
47    /// Most recent project-open timestamp in Unix seconds.
48    pub last_opened_at: Option<i64>,
49    /// Most recent session update timestamp in Unix seconds.
50    pub last_session_updated_at: Option<i64>,
51    /// Total output tokens accumulated by sessions in this project.
52    pub output_tokens: i64,
53    /// Persisted project checkout path.
54    pub path: String,
55    /// Total sessions belonging to the project.
56    pub session_count: i64,
57    /// Last project metadata update timestamp in Unix seconds.
58    pub updated_at: i64,
59}
60
61/// Project-focused persistence boundary used by app orchestration and tests.
62#[cfg_attr(test, mockall::automock)]
63#[async_trait]
64pub trait ProjectRepository: Send + Sync {
65    /// Looks up a project by identifier.
66    async fn get_project(&self, id: i64) -> Result<Option<ProjectRow>, DbError>;
67
68    /// Loads all configured projects with aggregated session stats.
69    async fn load_projects_with_stats(&self) -> Result<Vec<ProjectListRow>, DbError>;
70
71    #[cfg(test)]
72    /// Updates favorite state for one project.
73    async fn set_project_favorite(&self, project_id: i64, is_favorite: bool)
74    -> Result<(), DbError>;
75
76    /// Marks a project as recently opened at the current Unix timestamp.
77    async fn touch_project_last_opened(&self, project_id: i64) -> Result<(), DbError>;
78
79    /// Inserts or updates a project by path and returns its identifier.
80    async fn upsert_project(&self, path: &str, git_branch: Option<String>) -> Result<i64, DbError>;
81}
82
83/// `SQLite` implementation of [`ProjectRepository`].
84#[derive(Clone)]
85pub(crate) struct SqliteProjectRepository {
86    pool: SqlitePool,
87    timestamp_source: Arc<dyn TimestampSource>,
88}
89
90impl SqliteProjectRepository {
91    /// Creates a project repository backed by the provided pool.
92    pub(crate) fn new(pool: SqlitePool, timestamp_source: Arc<dyn TimestampSource>) -> Self {
93        Self {
94            pool,
95            timestamp_source,
96        }
97    }
98
99    /// Returns the shared persistence timestamp in Unix seconds.
100    fn now(&self) -> i64 {
101        self.timestamp_source.now_timestamp_seconds()
102    }
103}
104
105/// Scalar row used to return one required project identifier.
106struct ProjectIdValueRow {
107    value: i64,
108}
109
110/// Macro-mapped row returned when loading one project with optional joined
111/// session aggregates.
112struct ProjectListQueryRow {
113    active_session_count: Option<i64>,
114    created_at: i64,
115    display_name: Option<String>,
116    git_branch: Option<String>,
117    id: i64,
118    input_tokens: Option<i64>,
119    is_favorite: bool,
120    last_opened_at: Option<i64>,
121    last_session_updated_at: Option<i64>,
122    output_tokens: Option<i64>,
123    path: String,
124    session_count: Option<i64>,
125    updated_at: i64,
126}
127
128impl ProjectListQueryRow {
129    /// Converts optional joined aggregate values into the public row shape.
130    fn into_project_list_row(self) -> ProjectListRow {
131        let Self {
132            active_session_count,
133            created_at,
134            display_name,
135            git_branch,
136            id,
137            input_tokens,
138            is_favorite,
139            last_opened_at,
140            last_session_updated_at,
141            output_tokens,
142            path,
143            session_count,
144            updated_at,
145        } = self;
146
147        ProjectListRow {
148            active_session_count: active_session_count.unwrap_or(0),
149            created_at,
150            display_name,
151            git_branch,
152            id,
153            input_tokens: input_tokens.unwrap_or(0),
154            is_favorite,
155            last_opened_at,
156            last_session_updated_at,
157            output_tokens: output_tokens.unwrap_or(0),
158            path,
159            session_count: session_count.unwrap_or(0),
160            updated_at,
161        }
162    }
163}
164
165#[async_trait]
166impl ProjectRepository for SqliteProjectRepository {
167    async fn get_project(&self, id: i64) -> Result<Option<ProjectRow>, DbError> {
168        let row = sqlx::query_as!(
169            ProjectRow,
170            r#"
171SELECT created_at,
172       display_name,
173       git_branch,
174       id,
175       is_favorite AS "is_favorite: _",
176       last_opened_at,
177       path,
178       updated_at
179FROM project
180WHERE id = ?
181"#,
182            id
183        )
184        .fetch_optional(&self.pool)
185        .await?;
186
187        Ok(row)
188    }
189
190    async fn load_projects_with_stats(&self) -> Result<Vec<ProjectListRow>, DbError> {
191        let rows = sqlx::query_as!(
192            ProjectListQueryRow,
193            r#"
194WITH stats AS (
195    SELECT project_id,
196           MAX(updated_at) AS last_session_updated_at,
197           SUM(input_tokens) AS input_tokens,
198           SUM(output_tokens) AS output_tokens,
199           COUNT(*) AS session_count,
200           COUNT(CASE WHEN status NOT IN ('Done', 'Canceled', 'Queued', 'Merging')
201                      THEN 1 END) AS active_session_count
202    FROM session
203    WHERE project_id IS NOT NULL
204    GROUP BY project_id
205)
206SELECT stats.active_session_count,
207       p.created_at AS "created_at!",
208       p.display_name,
209       p.git_branch,
210       p.id AS "id!",
211       stats.input_tokens AS "input_tokens?: i64",
212       p.is_favorite AS "is_favorite: _",
213       p.last_opened_at,
214       stats.last_session_updated_at AS "last_session_updated_at?: i64",
215       stats.output_tokens AS "output_tokens?: i64",
216       p.path,
217       stats.session_count,
218       p.updated_at AS "updated_at!"
219FROM project AS p
220LEFT JOIN stats
221ON stats.project_id = p.id
222ORDER BY p.is_favorite DESC,
223         COALESCE(p.last_opened_at, 0) DESC,
224         p.path
225            "#
226        )
227        .fetch_all(&self.pool)
228        .await?;
229
230        Ok(rows
231            .into_iter()
232            .map(ProjectListQueryRow::into_project_list_row)
233            .collect())
234    }
235
236    #[cfg(test)]
237    async fn set_project_favorite(
238        &self,
239        project_id: i64,
240        is_favorite: bool,
241    ) -> Result<(), DbError> {
242        let now = self.now();
243
244        sqlx::query!(
245            r"
246UPDATE project
247SET is_favorite = ?,
248    updated_at = ?
249WHERE id = ?
250",
251            i64::from(is_favorite),
252            now,
253            project_id
254        )
255        .execute(&self.pool)
256        .await?;
257
258        Ok(())
259    }
260
261    async fn touch_project_last_opened(&self, project_id: i64) -> Result<(), DbError> {
262        let now = self.now();
263
264        sqlx::query!(
265            r"
266UPDATE project
267SET last_opened_at = ?,
268    updated_at = ?
269WHERE id = ?
270",
271            now,
272            now,
273            project_id
274        )
275        .execute(&self.pool)
276        .await?;
277
278        Ok(())
279    }
280
281    async fn upsert_project(&self, path: &str, git_branch: Option<String>) -> Result<i64, DbError> {
282        let now = self.now();
283
284        sqlx::query(
285            r"
286INSERT INTO project (path, git_branch, created_at, updated_at)
287VALUES (?, ?, ?, ?)
288ON CONFLICT(path) DO UPDATE
289SET git_branch = excluded.git_branch,
290    updated_at = excluded.updated_at
291",
292        )
293        .bind(path)
294        .bind(git_branch.as_deref())
295        .bind(now)
296        .bind(now)
297        .execute(&self.pool)
298        .await?;
299
300        let row = sqlx::query_as!(
301            ProjectIdValueRow,
302            r#"
303SELECT id AS "value!: _"
304FROM project
305WHERE path = ?
306"#,
307            path
308        )
309        .fetch_one(&self.pool)
310        .await?;
311
312        Ok(row.value)
313    }
314}