Skip to main content

seshat_storage/repository/
submodule_repository.rs

1//! SQLite implementation of [`SubmoduleRepository`].
2
3use std::sync::{Arc, Mutex};
4
5use rusqlite::{Connection, params};
6
7use super::{SubmoduleRepository, lock_conn};
8use crate::StorageError;
9
10/// A row from the `submodules` table.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct SubmoduleRow {
13    /// Auto-incremented primary key.
14    pub id: i64,
15    /// Mount path relative to repo root (e.g. `"vendor/lib"`).
16    pub relative_path: String,
17    /// Human-readable submodule name (typically the basename).
18    pub name: String,
19    /// Absolute path to the submodule's dedicated `.db` file.
20    pub db_path: String,
21    /// Current HEAD commit hash of the submodule (for change detection).
22    pub commit_hash: Option<String>,
23    /// ISO-8601 creation timestamp.
24    pub created_at: String,
25    /// ISO-8601 last-update timestamp.
26    pub updated_at: String,
27}
28
29/// Input for inserting or updating a submodule record.
30/// Does not include `id`, `created_at`, or `updated_at` (managed by DB).
31#[derive(Debug, Clone)]
32pub struct SubmoduleInput {
33    /// Mount path relative to repo root.
34    pub relative_path: String,
35    /// Human-readable submodule name.
36    pub name: String,
37    /// Absolute path to the submodule's dedicated `.db` file.
38    pub db_path: String,
39    /// Current HEAD commit hash.
40    pub commit_hash: Option<String>,
41}
42
43/// SQLite-backed submodule repository.
44#[derive(Debug, Clone)]
45pub struct SqliteSubmoduleRepository {
46    conn: Arc<Mutex<Connection>>,
47}
48
49impl SqliteSubmoduleRepository {
50    /// Create a new repository backed by the given connection.
51    pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
52        Self { conn }
53    }
54}
55
56impl SubmoduleRepository for SqliteSubmoduleRepository {
57    #[tracing::instrument(skip(self))]
58    fn insert(&self, input: &SubmoduleInput) -> Result<SubmoduleRow, StorageError> {
59        let conn = lock_conn(&self.conn)?;
60        conn.execute(
61            "INSERT INTO submodules (relative_path, name, db_path, commit_hash)
62             VALUES (?1, ?2, ?3, ?4)",
63            params![
64                input.relative_path,
65                input.name,
66                input.db_path,
67                input.commit_hash
68            ],
69        )?;
70        let id = conn.last_insert_rowid();
71
72        conn.query_row(
73            "SELECT id, relative_path, name, db_path, commit_hash, created_at, updated_at
74             FROM submodules WHERE id = ?1",
75            params![id],
76            row_to_submodule,
77        )
78        .map_err(Into::into)
79    }
80
81    #[tracing::instrument(skip(self))]
82    fn update(&self, input: &SubmoduleInput) -> Result<(), StorageError> {
83        let conn = lock_conn(&self.conn)?;
84        let affected = conn.execute(
85            "UPDATE submodules
86             SET name = ?1, db_path = ?2, commit_hash = ?3, updated_at = datetime('now')
87             WHERE relative_path = ?4",
88            params![
89                input.name,
90                input.db_path,
91                input.commit_hash,
92                input.relative_path
93            ],
94        )?;
95
96        if affected == 0 {
97            return Err(StorageError::NotFound {
98                entity: "Submodule",
99                id: input.relative_path.clone(),
100            });
101        }
102        Ok(())
103    }
104
105    #[tracing::instrument(skip(self))]
106    fn upsert(&self, input: &SubmoduleInput) -> Result<(), StorageError> {
107        let conn = lock_conn(&self.conn)?;
108        conn.execute(
109            "INSERT INTO submodules (relative_path, name, db_path, commit_hash)
110             VALUES (?1, ?2, ?3, ?4)
111             ON CONFLICT(relative_path) DO UPDATE SET
112                 name = excluded.name,
113                 db_path = excluded.db_path,
114                 commit_hash = excluded.commit_hash,
115                 updated_at = datetime('now')",
116            params![
117                input.relative_path,
118                input.name,
119                input.db_path,
120                input.commit_hash
121            ],
122        )?;
123        Ok(())
124    }
125
126    #[tracing::instrument(skip(self))]
127    fn delete(&self, relative_path: &str) -> Result<(), StorageError> {
128        let conn = lock_conn(&self.conn)?;
129        let affected = conn.execute(
130            "DELETE FROM submodules WHERE relative_path = ?1",
131            params![relative_path],
132        )?;
133
134        if affected == 0 {
135            return Err(StorageError::NotFound {
136                entity: "Submodule",
137                id: relative_path.to_string(),
138            });
139        }
140        Ok(())
141    }
142
143    #[tracing::instrument(skip(self))]
144    fn list(&self) -> Result<Vec<SubmoduleRow>, StorageError> {
145        let conn = lock_conn(&self.conn)?;
146        let mut stmt = conn.prepare(
147            "SELECT id, relative_path, name, db_path, commit_hash, created_at, updated_at
148             FROM submodules ORDER BY relative_path",
149        )?;
150        let rows = stmt.query_map([], row_to_submodule)?;
151        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
152    }
153
154    #[tracing::instrument(skip(self))]
155    fn find_by_path(&self, relative_path: &str) -> Result<Option<SubmoduleRow>, StorageError> {
156        let conn = lock_conn(&self.conn)?;
157        let result = conn.query_row(
158            "SELECT id, relative_path, name, db_path, commit_hash, created_at, updated_at
159             FROM submodules WHERE relative_path = ?1",
160            params![relative_path],
161            row_to_submodule,
162        );
163
164        match result {
165            Ok(row) => Ok(Some(row)),
166            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
167            Err(e) => Err(StorageError::from(e)),
168        }
169    }
170}
171
172/// Map a rusqlite `Row` to a [`SubmoduleRow`].
173fn row_to_submodule(row: &rusqlite::Row<'_>) -> rusqlite::Result<SubmoduleRow> {
174    Ok(SubmoduleRow {
175        id: row.get(0)?,
176        relative_path: row.get(1)?,
177        name: row.get(2)?,
178        db_path: row.get(3)?,
179        commit_hash: row.get(4)?,
180        created_at: row.get(5)?,
181        updated_at: row.get(6)?,
182    })
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::Database;
189
190    fn test_repo() -> SqliteSubmoduleRepository {
191        let db = Database::open(":memory:").expect("in-memory DB");
192        SqliteSubmoduleRepository::new(db.connection().clone())
193    }
194
195    fn make_input(path: &str) -> SubmoduleInput {
196        SubmoduleInput {
197            relative_path: path.to_string(),
198            name: path.rsplit('/').next().unwrap_or(path).to_string(),
199            db_path: format!("/data/seshat/repos/project/{path}.db"),
200            commit_hash: Some("abc123".to_string()),
201        }
202    }
203
204    #[test]
205    fn insert_and_find_by_path() {
206        let repo = test_repo();
207        let input = make_input("vendor/lib");
208
209        let inserted = repo.insert(&input).expect("insert should succeed");
210        assert_eq!(inserted.relative_path, "vendor/lib");
211        assert_eq!(inserted.name, "lib");
212        assert_eq!(inserted.db_path, "/data/seshat/repos/project/vendor/lib.db");
213        assert_eq!(inserted.commit_hash, Some("abc123".to_string()));
214        assert!(inserted.id > 0);
215
216        let found = repo
217            .find_by_path("vendor/lib")
218            .expect("find should succeed")
219            .expect("row should exist");
220        assert_eq!(found.id, inserted.id);
221        assert_eq!(found.relative_path, "vendor/lib");
222    }
223
224    #[test]
225    fn find_by_path_not_found() {
226        let repo = test_repo();
227        let result = repo
228            .find_by_path("nonexistent")
229            .expect("find should not error");
230        assert!(result.is_none());
231    }
232
233    #[test]
234    fn insert_duplicate_path_errors() {
235        let repo = test_repo();
236        let input = make_input("vendor/lib");
237
238        repo.insert(&input).expect("first insert should succeed");
239        let result = repo.insert(&input);
240        assert!(result.is_err(), "duplicate relative_path should fail");
241    }
242
243    #[test]
244    fn update_existing() {
245        let repo = test_repo();
246        let input = make_input("vendor/lib");
247        repo.insert(&input).expect("insert");
248
249        let updated_input = SubmoduleInput {
250            relative_path: "vendor/lib".to_string(),
251            name: "lib-renamed".to_string(),
252            db_path: "/data/seshat/repos/project/vendor/lib.db".to_string(),
253            commit_hash: Some("def456".to_string()),
254        };
255
256        repo.update(&updated_input).expect("update should succeed");
257
258        let found = repo.find_by_path("vendor/lib").unwrap().unwrap();
259        assert_eq!(found.name, "lib-renamed");
260        assert_eq!(found.commit_hash, Some("def456".to_string()));
261    }
262
263    #[test]
264    fn update_nonexistent_errors() {
265        let repo = test_repo();
266        let input = make_input("nonexistent");
267
268        let result = repo.update(&input);
269        assert!(result.is_err(), "updating nonexistent should fail");
270    }
271
272    #[test]
273    fn delete_existing() {
274        let repo = test_repo();
275        let input = make_input("vendor/lib");
276        repo.insert(&input).expect("insert");
277
278        repo.delete("vendor/lib").expect("delete should succeed");
279
280        let found = repo.find_by_path("vendor/lib").unwrap();
281        assert!(found.is_none(), "deleted row should not be found");
282    }
283
284    #[test]
285    fn delete_nonexistent_errors() {
286        let repo = test_repo();
287        let result = repo.delete("nonexistent");
288        assert!(result.is_err(), "deleting nonexistent should fail");
289    }
290
291    #[test]
292    fn list_returns_sorted_by_path() {
293        let repo = test_repo();
294        repo.insert(&make_input("vendor/z-lib")).expect("insert");
295        repo.insert(&make_input("vendor/a-lib")).expect("insert");
296        repo.insert(&make_input("deps/core")).expect("insert");
297
298        let rows = repo.list().expect("list should succeed");
299        assert_eq!(rows.len(), 3);
300        assert_eq!(rows[0].relative_path, "deps/core");
301        assert_eq!(rows[1].relative_path, "vendor/a-lib");
302        assert_eq!(rows[2].relative_path, "vendor/z-lib");
303    }
304
305    #[test]
306    fn list_empty() {
307        let repo = test_repo();
308        let rows = repo.list().expect("list should succeed");
309        assert!(rows.is_empty());
310    }
311
312    #[test]
313    fn insert_with_no_commit_hash() {
314        let repo = test_repo();
315        let input = SubmoduleInput {
316            relative_path: "vendor/lib".to_string(),
317            name: "lib".to_string(),
318            db_path: "/data/seshat/repos/project/vendor/lib.db".to_string(),
319            commit_hash: None,
320        };
321
322        let inserted = repo.insert(&input).expect("insert should succeed");
323        assert!(inserted.commit_hash.is_none());
324    }
325
326    #[test]
327    fn upsert_inserts_new() {
328        let repo = test_repo();
329        repo.upsert(&make_input("vendor/lib"))
330            .expect("upsert should succeed");
331
332        let found = repo.find_by_path("vendor/lib").unwrap().unwrap();
333        assert_eq!(found.relative_path, "vendor/lib");
334        assert_eq!(found.commit_hash, Some("abc123".to_string()));
335    }
336
337    #[test]
338    fn upsert_updates_existing() {
339        let repo = test_repo();
340        repo.insert(&make_input("vendor/lib")).expect("insert");
341
342        let updated = SubmoduleInput {
343            relative_path: "vendor/lib".to_string(),
344            name: "lib-v2".to_string(),
345            db_path: "/new/path.db".to_string(),
346            commit_hash: Some("def456".to_string()),
347        };
348        repo.upsert(&updated).expect("upsert should succeed");
349
350        let found = repo.find_by_path("vendor/lib").unwrap().unwrap();
351        assert_eq!(found.name, "lib-v2");
352        assert_eq!(found.db_path, "/new/path.db");
353        assert_eq!(found.commit_hash, Some("def456".to_string()));
354    }
355}