1use std::sync::Arc;
4
5use async_trait::async_trait;
6use sqlx::SqlitePool;
7
8use crate::DbError;
9use crate::timestamp::TimestampSource;
10
11pub struct ProjectRow {
13 pub created_at: i64,
15 pub display_name: Option<String>,
17 pub git_branch: Option<String>,
19 pub id: i64,
21 pub is_favorite: bool,
23 pub last_opened_at: Option<i64>,
25 pub path: String,
27 pub updated_at: i64,
29}
30
31pub struct ProjectListRow {
33 pub active_session_count: i64,
35 pub created_at: i64,
37 pub display_name: Option<String>,
39 pub git_branch: Option<String>,
41 pub id: i64,
43 pub input_tokens: i64,
45 pub is_favorite: bool,
47 pub last_opened_at: Option<i64>,
49 pub last_session_updated_at: Option<i64>,
51 pub output_tokens: i64,
53 pub path: String,
55 pub session_count: i64,
57 pub updated_at: i64,
59}
60
61#[cfg_attr(test, mockall::automock)]
63#[async_trait]
64pub trait ProjectRepository: Send + Sync {
65 async fn get_project(&self, id: i64) -> Result<Option<ProjectRow>, DbError>;
67
68 async fn load_projects_with_stats(&self) -> Result<Vec<ProjectListRow>, DbError>;
70
71 #[cfg(test)]
72 async fn set_project_favorite(&self, project_id: i64, is_favorite: bool)
74 -> Result<(), DbError>;
75
76 async fn touch_project_last_opened(&self, project_id: i64) -> Result<(), DbError>;
78
79 async fn upsert_project(&self, path: &str, git_branch: Option<String>) -> Result<i64, DbError>;
81}
82
83#[derive(Clone)]
85pub(crate) struct SqliteProjectRepository {
86 pool: SqlitePool,
87 timestamp_source: Arc<dyn TimestampSource>,
88}
89
90impl SqliteProjectRepository {
91 pub(crate) fn new(pool: SqlitePool, timestamp_source: Arc<dyn TimestampSource>) -> Self {
93 Self {
94 pool,
95 timestamp_source,
96 }
97 }
98
99 fn now(&self) -> i64 {
101 self.timestamp_source.now_timestamp_seconds()
102 }
103}
104
105struct ProjectIdValueRow {
107 value: i64,
108}
109
110struct 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 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}