Skip to main content

bonds_core/
manager.rs

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
9/// SQLite-backed manager for Bonds.
10pub struct BondManager {
11    conn: Connection,
12}
13
14impl BondManager {
15    /// Open (or create) the DB at `db_path`. If None, defaults to `$HOME/.bonds/bonds.db`.
16    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) // ← reuse the schema setup
31    }
32
33    /// List all bonds (most-recent first).
34    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    /// Parse a Bond from a rusqlite Row.
48    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    /// Get a single bond by ID or name. ID can be a unique prefix.
76    pub fn get_bond(&self, identifier: &str) -> Result<Bond, BondError> {
77        // 1. Try exact name match
78        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        // 2. Fall back to ID prefix match
90        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    /// Create a symlink bond and persist it.
108    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        // Validate name uniqueness if provided
118        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            // Allow targeting an empty directory (common after removing child bonds)
136            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            // Remove the empty dir so the symlink can take its place
146            std::fs::remove_dir(&tgt)?;
147        }
148
149        if let Some(parent) = tgt.parent() {
150            fs::create_dir_all(parent)?;
151        }
152
153        // Platform-specific symlink creation
154        #[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    /// Update a bond's source and/or target.
188    /// Replaces the symlink on disk and updates the DB record.
189    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        // Nothing to do if both are unchanged
214        if source == bond.source && target == bond.target && new_name.is_none() {
215            return Ok(bond);
216        }
217
218        // Remove the old symlink (if it still exists)
219        if bond.target.exists() || bond.target.symlink_metadata().is_ok() {
220            fs::remove_file(&bond.target)?;
221        }
222
223        // If target changed and something already exists at the new path, reject
224        if target != bond.target && target.exists() {
225            return Err(BondError::AlreadyExists);
226        }
227
228        // Create parent dirs for new target if needed
229        if let Some(parent) = target.parent() {
230            fs::create_dir_all(parent)?;
231        }
232
233        // Create the new symlink
234        #[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        // Update the DB record
246        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    /// Delete a bond by id. If `remove_target` is true, non-symlink targets are removed too.
265    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    /// Runs schema migration. Useful for testing with in-memory DBs.
292    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        // Migration: add name column (ignore error if it already exists)
305        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    /// Helper: creates a BondManager backed by in-memory SQLite.
318    fn test_manager() -> BondManager {
319        let conn = Connection::open_in_memory().unwrap();
320        BondManager::from_connection(conn).unwrap()
321    }
322
323    /// Helper: creates a real temp directory that acts as a bond source.
324    /// Returns (TempDir, PathBuf) -- hold onto TempDir so it doesn't drop.
325    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        // Verify it's in the DB
349        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        // Verify the symlink actually exists
355        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        // Create a non-empty directory at the target
380        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        // Also gone from DB
402        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        // bond2 was created second, should appear first (newest-first order)
432        assert_eq!(bonds[0].id, bond2.id);
433        assert_eq!(bonds[1].id, bond1.id);
434    }
435}