1use crate::bond::Bond;
2use crate::error::BondError;
3use chrono::{DateTime, Utc};
4use rusqlite::{Connection, params};
5use serde_json;
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf}; pub struct BondManager {
13 conn: Connection,
14}
15
16impl BondManager {
17 pub fn new(db_path: Option<PathBuf>) -> Result<Self, BondError> {
19 let db_path = db_path.unwrap_or_else(|| {
20 std::env::var("HOME")
21 .map(PathBuf::from)
22 .unwrap_or_else(|_| PathBuf::from("."))
23 .join(".bonds")
24 .join("bonds.db")
25 });
26
27 if let Some(parent) = db_path.parent() {
28 fs::create_dir_all(parent)?;
29 }
30
31 let conn = Connection::open(db_path)?;
32 Self::from_connection(conn) }
34
35 pub fn list_bonds(&self) -> Result<Vec<Bond>, BondError> {
37 let mut stmt = self.conn.prepare(
38 "SELECT id, name, source, target, created_at, metadata FROM bonds ORDER BY created_at DESC",
39 )?;
40 let mut rows = stmt.query([])?;
41
42 let mut out = Vec::new();
43 while let Some(row) = rows.next()? {
44 out.push(self.bond_from_row(row)?);
45 }
46 Ok(out)
47 }
48
49 fn bond_from_row(&self, row: &rusqlite::Row) -> Result<Bond, BondError> {
51 let id: String = row.get(0)?;
52 let name: Option<String> = row.get(1)?;
53 let source: String = row.get(2)?;
54 let target: String = row.get(3)?;
55 let created_at_str: String = row.get(4)?;
56 let metadata_json: Option<String> = row.get(5)?;
57
58 let created_at = DateTime::parse_from_rfc3339(&created_at_str)
59 .map(|dt| dt.with_timezone(&Utc))
60 .map_err(|e| BondError::InvalidTimestamp(e.to_string()))?;
61
62 let metadata = match metadata_json {
63 Some(s) => Some(serde_json::from_str(&s)?),
64 None => None,
65 };
66
67 Ok(Bond {
68 id,
69 name,
70 source: PathBuf::from(source),
71 target: PathBuf::from(target),
72 created_at,
73 metadata,
74 })
75 }
76
77 pub fn get_bond(&self, identifier: &str) -> Result<Bond, BondError> {
81 let mut stmt = self.conn.prepare(
83 "SELECT id, name, source, target, created_at, metadata FROM bonds WHERE name = ?1",
84 )?;
85 let mut rows = stmt.query(params![identifier])?;
86
87 if let Some(row) = rows.next()? {
88 return self.bond_from_row(row);
89 }
90 drop(rows);
91 drop(stmt);
92
93 let mut stmt = self.conn.prepare(
95 "SELECT id, name, source, target, created_at, metadata FROM bonds WHERE id LIKE ?1 || '%'",
96 )?;
97 let mut rows = stmt.query(params![identifier])?;
98
99 let first = match rows.next()? {
100 Some(row) => self.bond_from_row(row)?,
101 None => return Err(BondError::NotFound(identifier.to_string())),
102 };
103
104 if rows.next()?.is_some() {
105 return Err(BondError::AmbiguousId(identifier.to_string()));
106 }
107
108 Ok(first)
109 }
110
111 pub fn create_bond<P: AsRef<Path>, Q: AsRef<Path>>(
114 &self,
115 source: P,
116 target: Q,
117 name: Option<String>,
118 ) -> Result<Bond, BondError> {
119 self.create_bond_internal(source, target, name, None)
121 }
122
123 pub fn create_bond_with_metadata<P: AsRef<Path>, Q: AsRef<Path>>(
126 &self,
127 source: P,
128 target: Q,
129 name: Option<String>,
130 metadata: Option<HashMap<String, String>>,
131 ) -> Result<Bond, BondError> {
132 self.create_bond_internal(source, target, name, metadata)
134 }
135
136 fn create_bond_internal<P: AsRef<Path>, Q: AsRef<Path>>(
139 &self,
140 source: P,
141 target: Q,
142 name: Option<String>,
143 metadata: Option<HashMap<String, String>>,
144 ) -> Result<Bond, BondError> {
145 let src = source.as_ref().to_path_buf();
146 let tgt = target.as_ref().to_path_buf();
147
148 if let Some(ref n) = name {
150 let mut stmt = self
151 .conn
152 .prepare("SELECT COUNT(*) FROM bonds WHERE name = ?1")?;
153 let count: i64 = stmt.query_row(params![n], |row| row.get(0))?;
154 if count > 0 {
155 return Err(BondError::AlreadyExists);
156 }
157 }
158
159 if !src.exists() {
160 return Err(BondError::InvalidPath(format!(
161 "source does not exist: {:?}",
162 src
163 )));
164 }
165
166 if tgt.exists() {
167 let is_empty_dir = tgt.is_dir()
169 && std::fs::read_dir(&tgt)
170 .map(|mut d| d.next().is_none())
171 .unwrap_or(false);
172
173 if !is_empty_dir {
174 return Err(BondError::TargetExists(format!("{}", tgt.display())));
175 }
176
177 std::fs::remove_dir(&tgt)?;
178 }
179
180 if let Some(parent) = tgt.parent() {
181 fs::create_dir_all(parent)?;
182 }
183
184 #[cfg(unix)]
185 std::os::unix::fs::symlink(&src, &tgt)?;
186 #[cfg(windows)]
187 {
188 if src.is_dir() {
189 std::os::windows::fs::symlink_dir(&src, &tgt)?;
190 } else {
191 std::os::windows::fs::symlink_file(&src, &tgt)?;
192 }
193 }
194
195 let mut bond = Bond::new(src.clone(), tgt.clone(), name);
197 bond.metadata = metadata;
198
199 let metadata_json: Option<String> =
201 bond.metadata().map(serde_json::to_string).transpose()?;
202
203 self.conn.execute(
204 "INSERT INTO bonds (id, name, source, target, created_at, metadata) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
205 params![
206 bond.id(),
207 bond.name(),
208 bond.source().to_string_lossy().to_string(),
209 bond.target().to_string_lossy().to_string(),
210 bond.created_at_rfc3339(),
211 metadata_json
212 ],
213 )?;
214
215 Ok(bond)
216 }
217
218 pub fn update_bond(
222 &self,
223 id: &str,
224 new_source: Option<PathBuf>,
225 new_target: Option<PathBuf>,
226 new_name: Option<String>,
227 ) -> Result<Bond, BondError> {
228 let mut bond = self.get_bond(id)?;
229
230 let source = match new_source {
231 Some(s) => {
232 if !s.exists() {
233 return Err(BondError::InvalidPath(format!(
234 "source does not exist: {:?}",
235 s
236 )));
237 }
238 s
239 }
240 None => bond.source.clone(),
241 };
242
243 let target = new_target.unwrap_or_else(|| bond.target.clone());
244
245 if source == bond.source && target == bond.target && new_name.is_none() {
247 return Ok(bond);
248 }
249
250 if bond.target.exists() || bond.target.symlink_metadata().is_ok() {
252 fs::remove_file(&bond.target)?;
253 }
254
255 if target != bond.target && target.exists() {
257 return Err(BondError::AlreadyExists);
258 }
259
260 if let Some(parent) = target.parent() {
262 fs::create_dir_all(parent)?;
263 }
264
265 #[cfg(unix)]
267 std::os::unix::fs::symlink(&source, &target)?;
268 #[cfg(windows)]
269 {
270 if source.is_dir() {
271 std::os::windows::fs::symlink_dir(&source, &target)?;
272 } else {
273 std::os::windows::fs::symlink_file(&source, &target)?;
274 }
275 }
276
277 self.conn.execute(
279 "UPDATE bonds SET source = ?1, target = ?2, name = ?3 WHERE id = ?4",
280 params![
281 source.to_string_lossy().to_string(),
282 target.to_string_lossy().to_string(),
283 new_name.as_ref().or(bond.name.as_ref()),
284 bond.id,
285 ],
286 )?;
287
288 bond.source = source;
289 bond.target = target;
290 if new_name.is_some() {
291 bond.name = new_name;
292 }
293 Ok(bond)
294 }
295
296 pub fn update_bond_metadata(
299 &self,
300 identifier: &str,
301 metadata: Option<HashMap<String, String>>,
302 ) -> Result<Bond, BondError> {
303 let mut bond = self.get_bond(identifier)?;
305
306 let metadata_json: Option<String> =
307 metadata.as_ref().map(serde_json::to_string).transpose()?;
308
309 self.conn.execute(
310 "UPDATE bonds SET metadata = ?1 WHERE id = ?2",
311 params![metadata_json, bond.id()],
312 )?;
313
314 bond.metadata = metadata;
315 Ok(bond)
316 }
317
318 pub fn delete_bond(&self, id: &str, remove_target: bool) -> Result<Bond, BondError> {
321 let bond = self.get_bond(id)?;
322
323 if bond.target.exists() {
324 let meta = fs::symlink_metadata(&bond.target)?;
325 if meta.file_type().is_symlink() {
326 fs::remove_file(&bond.target)?;
327 } else if remove_target {
328 if bond.target.is_dir() {
329 fs::remove_dir_all(&bond.target)?;
330 } else {
331 fs::remove_file(&bond.target)?;
332 }
333 } else {
334 return Err(BondError::InvalidPath(format!(
335 "target exists and is not a symlink: {:?}",
336 bond.target
337 )));
338 }
339 }
340
341 self.conn
342 .execute("DELETE FROM bonds WHERE id = ?1", params![bond.id])?;
343 Ok(bond)
344 }
345
346 pub(crate) fn from_connection(conn: Connection) -> Result<Self, BondError> {
349 conn.execute_batch(
350 "CREATE TABLE IF NOT EXISTS bonds (
351 id TEXT PRIMARY KEY,
352 name TEXT,
353 source TEXT NOT NULL,
354 target TEXT NOT NULL,
355 created_at TEXT NOT NULL,
356 metadata TEXT
357 );",
358 )?;
359
360 let _ = conn.execute_batch("ALTER TABLE bonds ADD COLUMN name TEXT;");
362 let _ = conn.execute_batch("ALTER TABLE bonds ADD COLUMN metadata TEXT;");
364
365 Ok(Self { conn })
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use rusqlite::Connection;
373 use std::collections::HashMap;
374 use tempfile::TempDir; fn test_manager() -> BondManager {
378 let conn = Connection::open_in_memory().unwrap();
379 BondManager::from_connection(conn).unwrap()
380 }
381
382 fn temp_source() -> (TempDir, PathBuf) {
385 let dir = TempDir::new().unwrap();
386 let path = dir.path().to_path_buf();
387 (dir, path)
388 }
389
390 #[test]
391 fn list_bonds_empty() {
392 let mgr = test_manager();
393 let bonds = mgr.list_bonds().unwrap();
394 assert!(bonds.is_empty());
395 }
396
397 #[test]
398 #[cfg_attr(windows, ignore)]
399 fn create_and_get_bond() {
400 let mgr = test_manager();
401 let (_src_dir, src_path) = temp_source();
402 let tgt_dir = TempDir::new().unwrap();
403 let tgt_path = tgt_dir.path().join("link");
404
405 let bond = mgr.create_bond(&src_path, &tgt_path, None).unwrap();
406
407 let fetched = mgr.get_bond(&bond.id).unwrap();
409 assert_eq!(fetched.id, bond.id);
410 assert_eq!(fetched.source, src_path);
411 assert_eq!(fetched.target, tgt_path);
412
413 assert!(
415 tgt_path
416 .symlink_metadata()
417 .unwrap()
418 .file_type()
419 .is_symlink()
420 );
421 }
422
423 #[test]
424 fn create_bond_nonexistent_source() {
425 let mgr = test_manager();
426 let result = mgr.create_bond("/no/such/path", "/tmp/whatever", None);
427 assert!(matches!(result, Err(BondError::InvalidPath(_))));
428 }
429
430 #[test]
431 #[cfg_attr(windows, ignore)]
432 fn create_bond_target_already_exists() {
433 let mgr = test_manager();
434 let (_src_dir, src_path) = temp_source();
435 let tgt_dir = TempDir::new().unwrap();
436 let tgt_path = tgt_dir.path().join("occupied");
437
438 std::fs::create_dir(&tgt_path).unwrap();
440 std::fs::write(tgt_path.join("file.txt"), "data").unwrap();
441
442 let result = mgr.create_bond(&src_path, &tgt_path, None);
443 assert!(matches!(result, Err(BondError::TargetExists(_))));
444 }
445
446 #[test]
447 #[cfg_attr(windows, ignore)]
448 fn delete_bond_removes_symlink() {
449 let mgr = test_manager();
450 let (_src_dir, src_path) = temp_source();
451 let tgt_dir = TempDir::new().unwrap();
452 let tgt_path = tgt_dir.path().join("link");
453
454 let bond = mgr.create_bond(&src_path, &tgt_path, None).unwrap();
455 assert!(tgt_path.exists());
456
457 mgr.delete_bond(&bond.id, false).unwrap();
458 assert!(!tgt_path.exists());
459
460 assert!(matches!(
462 mgr.get_bond(&bond.id),
463 Err(BondError::NotFound(_))
464 ));
465 }
466
467 #[test]
468 fn delete_bond_not_found() {
469 let mgr = test_manager();
470 let result = mgr.delete_bond("nonexistent-id", false);
471 assert!(matches!(result, Err(BondError::NotFound(_))));
472 }
473
474 #[test]
475 #[cfg_attr(windows, ignore)]
476 fn list_bonds_ordered_by_newest() {
477 let mgr = test_manager();
478 let (_src1, src1) = temp_source();
479 let (_src2, src2) = temp_source();
480 let tgt_dir = TempDir::new().unwrap();
481
482 let bond1 = mgr
483 .create_bond(&src1, tgt_dir.path().join("a"), None)
484 .unwrap();
485 let bond2 = mgr
486 .create_bond(&src2, tgt_dir.path().join("b"), None)
487 .unwrap();
488
489 let bonds = mgr.list_bonds().unwrap();
490 assert_eq!(bonds[0].id, bond2.id);
492 assert_eq!(bonds[1].id, bond1.id);
493 }
494
495 #[test]
496 #[cfg_attr(windows, ignore)]
497 fn create_bond_with_metadata_round_trips() {
498 let mgr = test_manager();
499 let (_src_dir, src_path) = temp_source();
500 let tgt_dir = TempDir::new().unwrap();
501 let tgt_path = tgt_dir.path().join("link");
502
503 let mut metadata = HashMap::new();
504 metadata.insert("project".to_string(), "bonds".to_string());
505 metadata.insert("owner".to_string(), "core-team".to_string());
506
507 let created = mgr
508 .create_bond_with_metadata(
509 &src_path,
510 &tgt_path,
511 Some("meta-bond".into()),
512 Some(metadata.clone()),
513 )
514 .unwrap();
515
516 assert_eq!(created.metadata(), Some(&metadata));
517
518 let fetched = mgr.get_bond(created.id()).unwrap();
519 assert_eq!(fetched.metadata(), Some(&metadata));
520 }
521
522 #[test]
523 #[cfg_attr(windows, ignore)]
524 fn update_bond_metadata_set_and_clear() {
525 let mgr = test_manager();
526 let (_src_dir, src_path) = temp_source();
527 let tgt_dir = TempDir::new().unwrap();
528 let tgt_path = tgt_dir.path().join("link");
529
530 let created = mgr.create_bond(&src_path, &tgt_path, None).unwrap();
531 assert!(created.metadata().is_none());
532
533 let mut metadata = HashMap::new();
534 metadata.insert("env".to_string(), "dev".to_string());
535 metadata.insert("team".to_string(), "platform".to_string());
536
537 let updated = mgr
538 .update_bond_metadata(created.id(), Some(metadata.clone()))
539 .unwrap();
540 assert_eq!(updated.metadata(), Some(&metadata));
541
542 let fetched = mgr.get_bond(created.id()).unwrap();
543 assert_eq!(fetched.metadata(), Some(&metadata));
544
545 let cleared = mgr.update_bond_metadata(created.id(), None).unwrap();
546 assert!(cleared.metadata().is_none());
547
548 let fetched_again = mgr.get_bond(created.id()).unwrap();
549 assert!(fetched_again.metadata().is_none());
550 }
551}