1use crate::error::StorageError;
4use crate::pool::Storage;
5use chrono::{DateTime, Utc};
6use evolve_core::ids::{AdapterId, ConfigId, ProjectId};
7use uuid::Uuid;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Project {
12 pub id: ProjectId,
14 pub adapter_id: AdapterId,
16 pub root_path: String,
18 pub name: String,
20 pub created_at: DateTime<Utc>,
22 pub champion_config_id: Option<ConfigId>,
25}
26
27#[derive(Debug, Clone)]
29pub struct ProjectRepo<'a> {
30 storage: &'a Storage,
31}
32
33impl<'a> ProjectRepo<'a> {
34 pub fn new(storage: &'a Storage) -> Self {
36 Self { storage }
37 }
38
39 pub async fn insert(&self, project: &Project) -> Result<(), StorageError> {
41 sqlx::query(
42 "INSERT INTO projects
43 (id, adapter_id, root_path, name, created_at, champion_config_id)
44 VALUES (?, ?, ?, ?, ?, ?)",
45 )
46 .bind(project.id.to_string())
47 .bind(project.adapter_id.as_str())
48 .bind(&project.root_path)
49 .bind(&project.name)
50 .bind(project.created_at.to_rfc3339())
51 .bind(project.champion_config_id.map(|c| c.to_string()))
52 .execute(self.storage.pool())
53 .await?;
54 Ok(())
55 }
56
57 pub async fn get_by_id(&self, id: ProjectId) -> Result<Option<Project>, StorageError> {
59 let row: Option<(String, String, String, String, String, Option<String>)> = sqlx::query_as(
60 "SELECT id, adapter_id, root_path, name, created_at, champion_config_id
61 FROM projects WHERE id = ?",
62 )
63 .bind(id.to_string())
64 .fetch_optional(self.storage.pool())
65 .await?;
66 row.map(row_to_project).transpose()
67 }
68
69 pub async fn get_by_root_path(&self, root: &str) -> Result<Option<Project>, StorageError> {
71 let row: Option<(String, String, String, String, String, Option<String>)> = sqlx::query_as(
72 "SELECT id, adapter_id, root_path, name, created_at, champion_config_id
73 FROM projects WHERE root_path = ?",
74 )
75 .bind(root)
76 .fetch_optional(self.storage.pool())
77 .await?;
78 row.map(row_to_project).transpose()
79 }
80
81 pub async fn list(&self) -> Result<Vec<Project>, StorageError> {
83 let rows: Vec<(String, String, String, String, String, Option<String>)> = sqlx::query_as(
84 "SELECT id, adapter_id, root_path, name, created_at, champion_config_id
85 FROM projects ORDER BY created_at DESC",
86 )
87 .fetch_all(self.storage.pool())
88 .await?;
89 rows.into_iter().map(row_to_project).collect()
90 }
91
92 pub async fn delete(&self, id: ProjectId) -> Result<(), StorageError> {
94 sqlx::query("DELETE FROM projects WHERE id = ?")
95 .bind(id.to_string())
96 .execute(self.storage.pool())
97 .await?;
98 Ok(())
99 }
100
101 pub async fn set_champion(
103 &self,
104 id: ProjectId,
105 config_id: ConfigId,
106 ) -> Result<(), StorageError> {
107 sqlx::query("UPDATE projects SET champion_config_id = ? WHERE id = ?")
108 .bind(config_id.to_string())
109 .bind(id.to_string())
110 .execute(self.storage.pool())
111 .await?;
112 Ok(())
113 }
114}
115
116fn row_to_project(
117 (id, adapter_id, root_path, name, created_at, champion): (
118 String,
119 String,
120 String,
121 String,
122 String,
123 Option<String>,
124 ),
125) -> Result<Project, StorageError> {
126 Ok(Project {
127 id: ProjectId::from_uuid(Uuid::parse_str(&id)?),
128 adapter_id: AdapterId::new(adapter_id),
129 root_path,
130 name,
131 created_at: DateTime::parse_from_rfc3339(&created_at)
132 .map_err(|e| StorageError::Sqlx(sqlx::Error::Decode(Box::new(e))))?
133 .with_timezone(&Utc),
134 champion_config_id: champion
135 .map(|s| Uuid::parse_str(&s).map(ConfigId::from_uuid))
136 .transpose()?,
137 })
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 fn sample(adapter: &str, root: &str) -> Project {
145 Project {
146 id: ProjectId::new(),
147 adapter_id: AdapterId::new(adapter),
148 root_path: root.to_string(),
149 name: root.rsplit('/').next().unwrap_or(root).to_string(),
150 created_at: Utc::now(),
151 champion_config_id: None,
152 }
153 }
154
155 #[tokio::test]
156 async fn insert_then_get_by_id_roundtrips() {
157 let storage = Storage::in_memory_for_tests().await.unwrap();
158 let repo = ProjectRepo::new(&storage);
159 let p = sample("claude-code", "/tmp/proj-a");
160 repo.insert(&p).await.unwrap();
161 let back = repo.get_by_id(p.id).await.unwrap().unwrap();
162 assert_eq!(back.id, p.id);
163 assert_eq!(back.adapter_id.as_str(), "claude-code");
164 assert_eq!(back.root_path, "/tmp/proj-a");
165 }
166
167 #[tokio::test]
168 async fn get_by_id_returns_none_when_absent() {
169 let storage = Storage::in_memory_for_tests().await.unwrap();
170 let repo = ProjectRepo::new(&storage);
171 assert!(repo.get_by_id(ProjectId::new()).await.unwrap().is_none());
172 }
173
174 #[tokio::test]
175 async fn get_by_root_path_finds_inserted_project() {
176 let storage = Storage::in_memory_for_tests().await.unwrap();
177 let repo = ProjectRepo::new(&storage);
178 let p = sample("cursor", "/tmp/proj-b");
179 repo.insert(&p).await.unwrap();
180 let back = repo.get_by_root_path("/tmp/proj-b").await.unwrap().unwrap();
181 assert_eq!(back.id, p.id);
182 }
183
184 #[tokio::test]
185 async fn list_orders_by_created_at_desc() {
186 let storage = Storage::in_memory_for_tests().await.unwrap();
187 let repo = ProjectRepo::new(&storage);
188 let older = Project {
189 created_at: Utc::now() - chrono::Duration::hours(1),
190 ..sample("aider", "/tmp/older")
191 };
192 let newer = sample("aider", "/tmp/newer");
193 repo.insert(&older).await.unwrap();
194 repo.insert(&newer).await.unwrap();
195 let rows = repo.list().await.unwrap();
196 assert_eq!(rows.len(), 2);
197 assert_eq!(rows[0].id, newer.id);
198 assert_eq!(rows[1].id, older.id);
199 }
200
201 #[tokio::test]
202 async fn root_path_uniqueness_is_enforced() {
203 let storage = Storage::in_memory_for_tests().await.unwrap();
204 let repo = ProjectRepo::new(&storage);
205 let a = sample("claude-code", "/tmp/dup");
206 let b = sample("cursor", "/tmp/dup");
207 repo.insert(&a).await.unwrap();
208 let err = repo.insert(&b).await.unwrap_err();
209 assert!(
210 matches!(err, StorageError::Sqlx(sqlx::Error::Database(_))),
211 "expected UNIQUE violation; got {err:?}",
212 );
213 }
214
215 #[tokio::test]
216 async fn delete_removes_the_row() {
217 let storage = Storage::in_memory_for_tests().await.unwrap();
218 let repo = ProjectRepo::new(&storage);
219 let p = sample("claude-code", "/tmp/del");
220 repo.insert(&p).await.unwrap();
221 repo.delete(p.id).await.unwrap();
222 assert!(repo.get_by_id(p.id).await.unwrap().is_none());
223 }
224}