1use crate::bond::Bond;
2use crate::error::BondError;
3use chrono::{DateTime, Utc};
4use rusqlite::{Connection, params};
5use serde_json;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9pub struct BondManager {
11 conn: Connection,
12}
13
14impl BondManager {
15 pub fn new(db_path: Option<PathBuf>) -> Result<Self, BondError> {
17 let db_path = db_path.unwrap_or_else(|| {
18 std::env::var("HOME")
19 .map(PathBuf::from)
20 .unwrap_or_else(|_| PathBuf::from("."))
21 .join(".bonds")
22 .join("bonds.db")
23 });
24
25 if let Some(parent) = db_path.parent() {
26 fs::create_dir_all(parent)?;
27 }
28
29 let conn = Connection::open(db_path)?;
30 Self::from_connection(conn) }
32
33 pub fn list_bonds(&self) -> Result<Vec<Bond>, BondError> {
35 let mut stmt = self.conn.prepare(
36 "SELECT id, name, source, target, created_at, metadata FROM bonds ORDER BY created_at DESC",
37 )?;
38 let mut rows = stmt.query([])?;
39
40 let mut out = Vec::new();
41 while let Some(row) = rows.next()? {
42 out.push(self.bond_from_row(row)?);
43 }
44 Ok(out)
45 }
46
47 fn bond_from_row(&self, row: &rusqlite::Row) -> Result<Bond, BondError> {
49 let id: String = row.get(0)?;
50 let name: Option<String> = row.get(1)?;
51 let source: String = row.get(2)?;
52 let target: String = row.get(3)?;
53 let created_at_str: String = row.get(4)?;
54 let metadata_json: Option<String> = row.get(5)?;
55
56 let created_at = DateTime::parse_from_rfc3339(&created_at_str)
57 .map(|dt| dt.with_timezone(&Utc))
58 .map_err(|e| BondError::InvalidTimestamp(e.to_string()))?;
59
60 let metadata = match metadata_json {
61 Some(s) => Some(serde_json::from_str(&s)?),
62 None => None,
63 };
64
65 Ok(Bond {
66 id,
67 name,
68 source: PathBuf::from(source),
69 target: PathBuf::from(target),
70 created_at,
71 metadata,
72 })
73 }
74
75 pub fn get_bond(&self, identifier: &str) -> Result<Bond, BondError> {
77 let mut stmt = self.conn.prepare(
79 "SELECT id, name, source, target, created_at, metadata FROM bonds WHERE name = ?1",
80 )?;
81 let mut rows = stmt.query(params![identifier])?;
82
83 if let Some(row) = rows.next()? {
84 return self.bond_from_row(row);
85 }
86 drop(rows);
87 drop(stmt);
88
89 let mut stmt = self.conn.prepare(
91 "SELECT id, name, source, target, created_at, metadata FROM bonds WHERE id LIKE ?1 || '%'",
92 )?;
93 let mut rows = stmt.query(params![identifier])?;
94
95 let first = match rows.next()? {
96 Some(row) => self.bond_from_row(row)?,
97 None => return Err(BondError::NotFound(identifier.to_string())),
98 };
99
100 if rows.next()?.is_some() {
101 return Err(BondError::AmbiguousId(identifier.to_string()));
102 }
103
104 Ok(first)
105 }
106
107 pub fn create_bond<P: AsRef<Path>, Q: AsRef<Path>>(
109 &self,
110 source: P,
111 target: Q,
112 name: Option<String>,
113 ) -> Result<Bond, BondError> {
114 let src = source.as_ref().to_path_buf();
115 let tgt = target.as_ref().to_path_buf();
116
117 if let Some(ref n) = name {
119 let mut stmt = self
120 .conn
121 .prepare("SELECT COUNT(*) FROM bonds WHERE name = ?1")?;
122 let count: i64 = stmt.query_row(params![n], |row| row.get(0))?;
123 if count > 0 {
124 return Err(BondError::AlreadyExists);
125 }
126 }
127
128 if !src.exists() {
129 return Err(BondError::InvalidPath(format!(
130 "source does not exist: {:?}",
131 src
132 )));
133 }
134 if tgt.exists() {
135 let is_empty_dir = tgt.is_dir()
137 && std::fs::read_dir(&tgt)
138 .map(|mut d| d.next().is_none())
139 .unwrap_or(false);
140
141 if !is_empty_dir {
142 return Err(BondError::TargetExists(format!("{}", tgt.display())));
143 }
144
145 std::fs::remove_dir(&tgt)?;
147 }
148
149 if let Some(parent) = tgt.parent() {
150 fs::create_dir_all(parent)?;
151 }
152
153 #[cfg(unix)]
155 std::os::unix::fs::symlink(&src, &tgt)?;
156 #[cfg(windows)]
157 {
158 if src.is_dir() {
159 std::os::windows::fs::symlink_dir(&src, &tgt)?;
160 } else {
161 std::os::windows::fs::symlink_file(&src, &tgt)?;
162 }
163 }
164
165 let bond = Bond::new(src.clone(), tgt.clone(), name);
166 let metadata_json: Option<String> = bond
167 .metadata
168 .as_ref()
169 .map(serde_json::to_string)
170 .transpose()?;
171
172 self.conn.execute(
173 "INSERT INTO bonds (id, name, source, target, created_at, metadata) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
174 params![
175 bond.id,
176 bond.name,
177 bond.source.to_string_lossy().to_string(),
178 bond.target.to_string_lossy().to_string(),
179 bond.created_at_rfc3339(),
180 metadata_json
181 ],
182 )?;
183
184 Ok(bond)
185 }
186
187 pub fn update_bond(
190 &self,
191 id: &str,
192 new_source: Option<PathBuf>,
193 new_target: Option<PathBuf>,
194 new_name: Option<String>,
195 ) -> Result<Bond, BondError> {
196 let mut bond = self.get_bond(id)?;
197
198 let source = match new_source {
199 Some(s) => {
200 if !s.exists() {
201 return Err(BondError::InvalidPath(format!(
202 "source does not exist: {:?}",
203 s
204 )));
205 }
206 s
207 }
208 None => bond.source.clone(),
209 };
210
211 let target = new_target.unwrap_or_else(|| bond.target.clone());
212
213 if source == bond.source && target == bond.target && new_name.is_none() {
215 return Ok(bond);
216 }
217
218 if bond.target.exists() || bond.target.symlink_metadata().is_ok() {
220 fs::remove_file(&bond.target)?;
221 }
222
223 if target != bond.target && target.exists() {
225 return Err(BondError::AlreadyExists);
226 }
227
228 if let Some(parent) = target.parent() {
230 fs::create_dir_all(parent)?;
231 }
232
233 #[cfg(unix)]
235 std::os::unix::fs::symlink(&source, &target)?;
236 #[cfg(windows)]
237 {
238 if source.is_dir() {
239 std::os::windows::fs::symlink_dir(&source, &target)?;
240 } else {
241 std::os::windows::fs::symlink_file(&source, &target)?;
242 }
243 }
244
245 self.conn.execute(
247 "UPDATE bonds SET source = ?1, target = ?2, name = ?3 WHERE id = ?4",
248 params![
249 source.to_string_lossy().to_string(),
250 target.to_string_lossy().to_string(),
251 new_name.as_ref().or(bond.name.as_ref()),
252 bond.id,
253 ],
254 )?;
255
256 bond.source = source;
257 bond.target = target;
258 if new_name.is_some() {
259 bond.name = new_name;
260 }
261 Ok(bond)
262 }
263
264 pub fn delete_bond(&self, id: &str, remove_target: bool) -> Result<Bond, BondError> {
266 let bond = self.get_bond(id)?;
267
268 if bond.target.exists() {
269 let meta = fs::symlink_metadata(&bond.target)?;
270 if meta.file_type().is_symlink() {
271 fs::remove_file(&bond.target)?;
272 } else if remove_target {
273 if bond.target.is_dir() {
274 fs::remove_dir_all(&bond.target)?;
275 } else {
276 fs::remove_file(&bond.target)?;
277 }
278 } else {
279 return Err(BondError::InvalidPath(format!(
280 "target exists and is not a symlink: {:?}",
281 bond.target
282 )));
283 }
284 }
285
286 self.conn
287 .execute("DELETE FROM bonds WHERE id = ?1", params![bond.id])?;
288 Ok(bond)
289 }
290
291 pub(crate) fn from_connection(conn: Connection) -> Result<Self, BondError> {
293 conn.execute_batch(
294 "CREATE TABLE IF NOT EXISTS bonds (
295 id TEXT PRIMARY KEY,
296 name TEXT,
297 source TEXT NOT NULL,
298 target TEXT NOT NULL,
299 created_at TEXT NOT NULL,
300 metadata TEXT
301 );",
302 )?;
303
304 let _ = conn.execute_batch("ALTER TABLE bonds ADD COLUMN name TEXT;");
306
307 Ok(Self { conn })
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use rusqlite::Connection;
315 use tempfile::TempDir;
316
317 fn test_manager() -> BondManager {
319 let conn = Connection::open_in_memory().unwrap();
320 BondManager::from_connection(conn).unwrap()
321 }
322
323 fn temp_source() -> (TempDir, PathBuf) {
326 let dir = TempDir::new().unwrap();
327 let path = dir.path().to_path_buf();
328 (dir, path)
329 }
330
331 #[test]
332 fn list_bonds_empty() {
333 let mgr = test_manager();
334 let bonds = mgr.list_bonds().unwrap();
335 assert!(bonds.is_empty());
336 }
337
338 #[test]
339 #[cfg_attr(windows, ignore)]
340 fn create_and_get_bond() {
341 let mgr = test_manager();
342 let (_src_dir, src_path) = temp_source();
343 let tgt_dir = TempDir::new().unwrap();
344 let tgt_path = tgt_dir.path().join("link");
345
346 let bond = mgr.create_bond(&src_path, &tgt_path, None).unwrap();
347
348 let fetched = mgr.get_bond(&bond.id).unwrap();
350 assert_eq!(fetched.id, bond.id);
351 assert_eq!(fetched.source, src_path);
352 assert_eq!(fetched.target, tgt_path);
353
354 assert!(
356 tgt_path
357 .symlink_metadata()
358 .unwrap()
359 .file_type()
360 .is_symlink()
361 );
362 }
363
364 #[test]
365 fn create_bond_nonexistent_source() {
366 let mgr = test_manager();
367 let result = mgr.create_bond("/no/such/path", "/tmp/whatever", None);
368 assert!(matches!(result, Err(BondError::InvalidPath(_))));
369 }
370
371 #[test]
372 #[cfg_attr(windows, ignore)]
373 fn create_bond_target_already_exists() {
374 let mgr = test_manager();
375 let (_src_dir, src_path) = temp_source();
376 let tgt_dir = TempDir::new().unwrap();
377 let tgt_path = tgt_dir.path().join("occupied");
378
379 std::fs::create_dir(&tgt_path).unwrap();
381 std::fs::write(tgt_path.join("file.txt"), "data").unwrap();
382
383 let result = mgr.create_bond(&src_path, &tgt_path, None);
384 assert!(matches!(result, Err(BondError::TargetExists(_))));
385 }
386
387 #[test]
388 #[cfg_attr(windows, ignore)]
389 fn delete_bond_removes_symlink() {
390 let mgr = test_manager();
391 let (_src_dir, src_path) = temp_source();
392 let tgt_dir = TempDir::new().unwrap();
393 let tgt_path = tgt_dir.path().join("link");
394
395 let bond = mgr.create_bond(&src_path, &tgt_path, None).unwrap();
396 assert!(tgt_path.exists());
397
398 mgr.delete_bond(&bond.id, false).unwrap();
399 assert!(!tgt_path.exists());
400
401 assert!(matches!(
403 mgr.get_bond(&bond.id),
404 Err(BondError::NotFound(_))
405 ));
406 }
407
408 #[test]
409 fn delete_bond_not_found() {
410 let mgr = test_manager();
411 let result = mgr.delete_bond("nonexistent-id", false);
412 assert!(matches!(result, Err(BondError::NotFound(_))));
413 }
414
415 #[test]
416 #[cfg_attr(windows, ignore)]
417 fn list_bonds_ordered_by_newest() {
418 let mgr = test_manager();
419 let (_src1, src1) = temp_source();
420 let (_src2, src2) = temp_source();
421 let tgt_dir = TempDir::new().unwrap();
422
423 let bond1 = mgr
424 .create_bond(&src1, tgt_dir.path().join("a"), None)
425 .unwrap();
426 let bond2 = mgr
427 .create_bond(&src2, tgt_dir.path().join("b"), None)
428 .unwrap();
429
430 let bonds = mgr.list_bonds().unwrap();
431 assert_eq!(bonds[0].id, bond2.id);
433 assert_eq!(bonds[1].id, bond1.id);
434 }
435}