Skip to main content

evolve_storage/
projects.rs

1//! Repository for the `projects` table.
2
3use crate::error::StorageError;
4use crate::pool::Storage;
5use chrono::{DateTime, Utc};
6use evolve_core::ids::{AdapterId, ConfigId, ProjectId};
7use uuid::Uuid;
8
9/// Row in the `projects` table.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Project {
12    /// Project identity.
13    pub id: ProjectId,
14    /// Adapter that manages this project ("claude-code", "cursor", "aider").
15    pub adapter_id: AdapterId,
16    /// Canonical absolute path to the project root.
17    pub root_path: String,
18    /// Human-readable name (usually the basename of `root_path`).
19    pub name: String,
20    /// Creation timestamp.
21    pub created_at: DateTime<Utc>,
22    /// Current champion config id; `None` only during `init` before the first
23    /// AgentConfig row has been written.
24    pub champion_config_id: Option<ConfigId>,
25}
26
27/// Repository for `projects`.
28#[derive(Debug, Clone)]
29pub struct ProjectRepo<'a> {
30    storage: &'a Storage,
31}
32
33impl<'a> ProjectRepo<'a> {
34    /// Construct a new repo borrowing the storage handle.
35    pub fn new(storage: &'a Storage) -> Self {
36        Self { storage }
37    }
38
39    /// Insert a new project row. Caller supplies the id.
40    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    /// Fetch by id; returns `Ok(None)` if no row matches.
58    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    /// Fetch by root path.
70    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    /// List all projects, most recently created first.
82    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    /// Delete a project and cascade (configs, experiments, sessions, signals go too).
93    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    /// Update the champion config pointer (used when a challenger is promoted).
102    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}