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::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf}; // Metadata map type for API methods
9
10/// SQLite-backed manager for Bonds.
11/// The `BondManager` struct provides high-level methods for managing the lifecycle of bonds, including creating, retrieving, updating, and deleting bonds. It handles the underlying SQLite database connection and schema management, as well as the filesystem operations required to create and update symlinks. The manager ensures that bond records are kept in sync with the actual state of the filesystem and provides error handling for various edge cases, such as invalid paths or conflicts with existing files.
12pub struct BondManager {
13    conn: Connection,
14}
15
16impl BondManager {
17    /// Open (or create) the DB at `db_path`. If None, defaults to `$HOME/.bonds/bonds.db`.
18    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) // ← reuse the schema setup
33    }
34
35    /// List all bonds (most-recent first).
36    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    /// Parse a Bond from a rusqlite Row.
50    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    /// Get a single bond by ID or name. ID can be a unique prefix.
78    /// First tries exact name match, then falls back to ID prefix match. Errors if not found or if ID prefix is ambiguous.
79    /// This method is used by CLI commands that accept either an ID or name as an identifier for a bond.
80    pub fn get_bond(&self, identifier: &str) -> Result<Bond, BondError> {
81        // 1. Try exact name match
82        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        // 2. Fall back to ID prefix match
94        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    /// Create a symlink bond and persist it (no metadata).
112    /// This is the main method used by CLI commands to create bonds, and it keeps the signature simple for that use case. Library users who want metadata can call `create_bond_with_metadata` instead.
113    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        // Keep legacy signature stable for existing CLI/API callers.
120        self.create_bond_internal(source, target, name, None)
121    }
122
123    /// Create a symlink bond with metadata and persist it.
124    /// This method is intended for library users who want to set metadata at creation time. It has a more complex signature than `create_bond`, but it avoids the need for a separate "update metadata" call after creation.
125    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        // New API path: lets library users persist metadata at creation time.
133        self.create_bond_internal(source, target, name, metadata)
134    }
135
136    /// Shared implementation used by both create methods.
137    /// This method performs the actual work of validating paths, creating the symlink, and inserting the record into the database. It is not exposed publicly because it has a more complex signature that includes metadata, which is not needed for the common CLI use case.
138    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        // Validate name uniqueness if provided.
149        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            // Allow replacing an empty directory at target path.
168            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        // Keep returned Bond and DB row in sync.
196        let mut bond = Bond::new(src.clone(), tgt.clone(), name);
197        bond.metadata = metadata;
198
199        // Store metadata as JSON in SQLite TEXT column.
200        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    /// Update a bond's source and/or target.
219    /// Replaces the symlink on disk and updates the DB record.
220    /// This method is used by the CLI update command to modify the source or target paths of an existing bond. It validates the new paths, ensures that the target path does not conflict with existing files, updates the symlink on disk, and then updates the corresponding record in the SQLite database. The method returns the updated Bond object after successful completion.
221    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        // Nothing to do if both are unchanged
246        if source == bond.source && target == bond.target && new_name.is_none() {
247            return Ok(bond);
248        }
249
250        // Remove the old symlink (if it still exists)
251        if bond.target.exists() || bond.target.symlink_metadata().is_ok() {
252            fs::remove_file(&bond.target)?;
253        }
254
255        // If target changed and something already exists at the new path, reject
256        if target != bond.target && target.exists() {
257            return Err(BondError::AlreadyExists);
258        }
259
260        // Create parent dirs for new target if needed
261        if let Some(parent) = target.parent() {
262            fs::create_dir_all(parent)?;
263        }
264
265        // Create the new symlink
266        #[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        // Update the DB record
278        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    /// Replace a bond's metadata. Pass `None` to clear metadata entirely.
297    /// This method is used by the CLI command to update the metadata of an existing bond. It accepts a bond identifier (ID or name) and a new metadata map, which can be set to `None` to clear existing metadata. The method updates the metadata in the SQLite database and returns the updated Bond object with the new metadata. This allows users to manage custom key/value pairs associated with their bonds without affecting the source or target paths.
298    pub fn update_bond_metadata(
299        &self,
300        identifier: &str,
301        metadata: Option<HashMap<String, String>>,
302    ) -> Result<Bond, BondError> {
303        // Reuse existing identifier resolution (name, full ID, or unique ID prefix).
304        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    /// Delete a bond by id. If `remove_target` is true, non-symlink targets are removed too.
319    /// This method is used by the CLI delete command to remove an existing bond. It first retrieves the bond by its identifier, checks if the target path exists, and if it does, it determines whether it's a symlink or a regular file/directory. If it's a symlink, it removes it. If it's not a symlink and `remove_target` is true, it removes the file or directory at the target path. Finally, it deletes the bond record from the SQLite database and returns the deleted Bond object. This allows users to clean up bonds and optionally remove the target files/directories they point to.
320    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    /// Runs schema migration. Useful for testing with in-memory DBs.
347    /// This method is called internally when creating a BondManager from a rusqlite Connection. It ensures that the necessary tables and columns exist in the database, creating them if they are missing. This allows the application to work with both new and existing databases without requiring manual migration steps. The method handles the creation of the `bonds` table and the addition of new columns for name and metadata, while ignoring errors if those columns already exist (which can happen when connecting to an older database).
348    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        // Migration: add name column (ignore error if it already exists)
361        let _ = conn.execute_batch("ALTER TABLE bonds ADD COLUMN name TEXT;");
362        // Migration: add metadata column for older databases (ignore if it already exists).
363        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; // Needed for metadata assertions
375
376    /// Helper: creates a BondManager backed by in-memory SQLite.
377    fn test_manager() -> BondManager {
378        let conn = Connection::open_in_memory().unwrap();
379        BondManager::from_connection(conn).unwrap()
380    }
381
382    /// Helper: creates a real temp directory that acts as a bond source.
383    /// Returns (TempDir, PathBuf) -- hold onto TempDir so it doesn't drop.
384    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        // Verify it's in the DB
408        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        // Verify the symlink actually exists
414        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        // Create a non-empty directory at the target
439        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        // Also gone from DB
461        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        // bond2 was created second, should appear first (newest-first order)
491        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}