seshat_storage/repository/
submodule_repository.rs1use std::sync::{Arc, Mutex};
4
5use rusqlite::{Connection, params};
6
7use super::{SubmoduleRepository, lock_conn};
8use crate::StorageError;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct SubmoduleRow {
13 pub id: i64,
15 pub relative_path: String,
17 pub name: String,
19 pub db_path: String,
21 pub commit_hash: Option<String>,
23 pub created_at: String,
25 pub updated_at: String,
27}
28
29#[derive(Debug, Clone)]
32pub struct SubmoduleInput {
33 pub relative_path: String,
35 pub name: String,
37 pub db_path: String,
39 pub commit_hash: Option<String>,
41}
42
43#[derive(Debug, Clone)]
45pub struct SqliteSubmoduleRepository {
46 conn: Arc<Mutex<Connection>>,
47}
48
49impl SqliteSubmoduleRepository {
50 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
172fn 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}