bonds-core 0.1.4

Core library for managing symlink-based bonds with SQLite persistence
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
use crate::bond::Bond;
use crate::error::BondError;
use chrono::{DateTime, Utc};
use rusqlite::{Connection, params};
use serde_json;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf}; // Metadata map type for API methods

/// SQLite-backed manager for Bonds.
/// 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.
pub struct BondManager {
    conn: Connection,
}

impl BondManager {
    /// Open (or create) the DB at `db_path`. If None, defaults to `$HOME/.bonds/bonds.db`.
    pub fn new(db_path: Option<PathBuf>) -> Result<Self, BondError> {
        let db_path = db_path.unwrap_or_else(|| {
            std::env::var("HOME")
                .map(PathBuf::from)
                .unwrap_or_else(|_| PathBuf::from("."))
                .join(".bonds")
                .join("bonds.db")
        });

        if let Some(parent) = db_path.parent() {
            fs::create_dir_all(parent)?;
        }

        let conn = Connection::open(db_path)?;
        Self::from_connection(conn) // ← reuse the schema setup
    }

    /// List all bonds (most-recent first).
    pub fn list_bonds(&self) -> Result<Vec<Bond>, BondError> {
        let mut stmt = self.conn.prepare(
        "SELECT id, name, source, target, created_at, metadata FROM bonds ORDER BY created_at DESC",
    )?;
        let mut rows = stmt.query([])?;

        let mut out = Vec::new();
        while let Some(row) = rows.next()? {
            out.push(self.bond_from_row(row)?);
        }
        Ok(out)
    }

    /// Parse a Bond from a rusqlite Row.
    fn bond_from_row(&self, row: &rusqlite::Row) -> Result<Bond, BondError> {
        let id: String = row.get(0)?;
        let name: Option<String> = row.get(1)?;
        let source: String = row.get(2)?;
        let target: String = row.get(3)?;
        let created_at_str: String = row.get(4)?;
        let metadata_json: Option<String> = row.get(5)?;

        let created_at = DateTime::parse_from_rfc3339(&created_at_str)
            .map(|dt| dt.with_timezone(&Utc))
            .map_err(|e| BondError::InvalidTimestamp(e.to_string()))?;

        let metadata = match metadata_json {
            Some(s) => Some(serde_json::from_str(&s)?),
            None => None,
        };

        Ok(Bond {
            id,
            name,
            source: PathBuf::from(source),
            target: PathBuf::from(target),
            created_at,
            metadata,
        })
    }

    /// Get a single bond by ID or name. ID can be a unique prefix.
    /// First tries exact name match, then falls back to ID prefix match. Errors if not found or if ID prefix is ambiguous.
    /// This method is used by CLI commands that accept either an ID or name as an identifier for a bond.
    pub fn get_bond(&self, identifier: &str) -> Result<Bond, BondError> {
        // 1. Try exact name match
        let mut stmt = self.conn.prepare(
            "SELECT id, name, source, target, created_at, metadata FROM bonds WHERE name = ?1",
        )?;
        let mut rows = stmt.query(params![identifier])?;

        if let Some(row) = rows.next()? {
            return self.bond_from_row(row);
        }
        drop(rows);
        drop(stmt);

        // 2. Fall back to ID prefix match
        let mut stmt = self.conn.prepare(
        "SELECT id, name, source, target, created_at, metadata FROM bonds WHERE id LIKE ?1 || '%'",
    )?;
        let mut rows = stmt.query(params![identifier])?;

        let first = match rows.next()? {
            Some(row) => self.bond_from_row(row)?,
            None => return Err(BondError::NotFound(identifier.to_string())),
        };

        if rows.next()?.is_some() {
            return Err(BondError::AmbiguousId(identifier.to_string()));
        }

        Ok(first)
    }

    /// Create a symlink bond and persist it (no metadata).
    /// 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.
    pub fn create_bond<P: AsRef<Path>, Q: AsRef<Path>>(
        &self,
        source: P,
        target: Q,
        name: Option<String>,
    ) -> Result<Bond, BondError> {
        // Keep legacy signature stable for existing CLI/API callers.
        self.create_bond_internal(source, target, name, None)
    }

    /// Create a symlink bond with metadata and persist it.
    /// 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.
    pub fn create_bond_with_metadata<P: AsRef<Path>, Q: AsRef<Path>>(
        &self,
        source: P,
        target: Q,
        name: Option<String>,
        metadata: Option<HashMap<String, String>>,
    ) -> Result<Bond, BondError> {
        // New API path: lets library users persist metadata at creation time.
        self.create_bond_internal(source, target, name, metadata)
    }

    /// Shared implementation used by both create methods.
    /// 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.
    fn create_bond_internal<P: AsRef<Path>, Q: AsRef<Path>>(
        &self,
        source: P,
        target: Q,
        name: Option<String>,
        metadata: Option<HashMap<String, String>>,
    ) -> Result<Bond, BondError> {
        let src = source.as_ref().to_path_buf();
        let tgt = target.as_ref().to_path_buf();

        // Validate name uniqueness if provided.
        if let Some(ref n) = name {
            let mut stmt = self
                .conn
                .prepare("SELECT COUNT(*) FROM bonds WHERE name = ?1")?;
            let count: i64 = stmt.query_row(params![n], |row| row.get(0))?;
            if count > 0 {
                return Err(BondError::AlreadyExists);
            }
        }

        if !src.exists() {
            return Err(BondError::InvalidPath(format!(
                "source does not exist: {:?}",
                src
            )));
        }

        if tgt.exists() {
            // Allow replacing an empty directory at target path.
            let is_empty_dir = tgt.is_dir()
                && std::fs::read_dir(&tgt)
                    .map(|mut d| d.next().is_none())
                    .unwrap_or(false);

            if !is_empty_dir {
                return Err(BondError::TargetExists(format!("{}", tgt.display())));
            }

            std::fs::remove_dir(&tgt)?;
        }

        if let Some(parent) = tgt.parent() {
            fs::create_dir_all(parent)?;
        }

        #[cfg(unix)]
        std::os::unix::fs::symlink(&src, &tgt)?;
        #[cfg(windows)]
        {
            if src.is_dir() {
                std::os::windows::fs::symlink_dir(&src, &tgt)?;
            } else {
                std::os::windows::fs::symlink_file(&src, &tgt)?;
            }
        }

        // Keep returned Bond and DB row in sync.
        let mut bond = Bond::new(src.clone(), tgt.clone(), name);
        bond.metadata = metadata;

        // Store metadata as JSON in SQLite TEXT column.
        let metadata_json: Option<String> =
            bond.metadata().map(serde_json::to_string).transpose()?;

        self.conn.execute(
        "INSERT INTO bonds (id, name, source, target, created_at, metadata) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
        params![
            bond.id(),
            bond.name(),
            bond.source().to_string_lossy().to_string(),
            bond.target().to_string_lossy().to_string(),
            bond.created_at_rfc3339(),
            metadata_json
        ],
    )?;

        Ok(bond)
    }

    /// Update a bond's source and/or target.
    /// Replaces the symlink on disk and updates the DB record.
    /// 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.
    pub fn update_bond(
        &self,
        id: &str,
        new_source: Option<PathBuf>,
        new_target: Option<PathBuf>,
        new_name: Option<String>,
    ) -> Result<Bond, BondError> {
        let mut bond = self.get_bond(id)?;

        let source = match new_source {
            Some(s) => {
                if !s.exists() {
                    return Err(BondError::InvalidPath(format!(
                        "source does not exist: {:?}",
                        s
                    )));
                }
                s
            }
            None => bond.source.clone(),
        };

        let target = new_target.unwrap_or_else(|| bond.target.clone());

        // Nothing to do if both are unchanged
        if source == bond.source && target == bond.target && new_name.is_none() {
            return Ok(bond);
        }

        // Remove the old symlink (if it still exists)
        if bond.target.exists() || bond.target.symlink_metadata().is_ok() {
            fs::remove_file(&bond.target)?;
        }

        // If target changed and something already exists at the new path, reject
        if target != bond.target && target.exists() {
            return Err(BondError::AlreadyExists);
        }

        // Create parent dirs for new target if needed
        if let Some(parent) = target.parent() {
            fs::create_dir_all(parent)?;
        }

        // Create the new symlink
        #[cfg(unix)]
        std::os::unix::fs::symlink(&source, &target)?;
        #[cfg(windows)]
        {
            if source.is_dir() {
                std::os::windows::fs::symlink_dir(&source, &target)?;
            } else {
                std::os::windows::fs::symlink_file(&source, &target)?;
            }
        }

        // Update the DB record
        self.conn.execute(
            "UPDATE bonds SET source = ?1, target = ?2, name = ?3 WHERE id = ?4",
            params![
                source.to_string_lossy().to_string(),
                target.to_string_lossy().to_string(),
                new_name.as_ref().or(bond.name.as_ref()),
                bond.id,
            ],
        )?;

        bond.source = source;
        bond.target = target;
        if new_name.is_some() {
            bond.name = new_name;
        }
        Ok(bond)
    }

    /// Replace a bond's metadata. Pass `None` to clear metadata entirely.
    /// 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.
    pub fn update_bond_metadata(
        &self,
        identifier: &str,
        metadata: Option<HashMap<String, String>>,
    ) -> Result<Bond, BondError> {
        // Reuse existing identifier resolution (name, full ID, or unique ID prefix).
        let mut bond = self.get_bond(identifier)?;

        let metadata_json: Option<String> =
            metadata.as_ref().map(serde_json::to_string).transpose()?;

        self.conn.execute(
            "UPDATE bonds SET metadata = ?1 WHERE id = ?2",
            params![metadata_json, bond.id()],
        )?;

        bond.metadata = metadata;
        Ok(bond)
    }

    /// Delete a bond by id. If `remove_target` is true, non-symlink targets are removed too.
    /// 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.
    pub fn delete_bond(&self, id: &str, remove_target: bool) -> Result<Bond, BondError> {
        let bond = self.get_bond(id)?;

        if bond.target.exists() {
            let meta = fs::symlink_metadata(&bond.target)?;
            if meta.file_type().is_symlink() {
                fs::remove_file(&bond.target)?;
            } else if remove_target {
                if bond.target.is_dir() {
                    fs::remove_dir_all(&bond.target)?;
                } else {
                    fs::remove_file(&bond.target)?;
                }
            } else {
                return Err(BondError::InvalidPath(format!(
                    "target exists and is not a symlink: {:?}",
                    bond.target
                )));
            }
        }

        self.conn
            .execute("DELETE FROM bonds WHERE id = ?1", params![bond.id])?;
        Ok(bond)
    }

    /// Runs schema migration. Useful for testing with in-memory DBs.
    /// 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).
    pub(crate) fn from_connection(conn: Connection) -> Result<Self, BondError> {
        conn.execute_batch(
            "CREATE TABLE IF NOT EXISTS bonds (
            id TEXT PRIMARY KEY,
            name TEXT,
            source TEXT NOT NULL,
            target TEXT NOT NULL,
            created_at TEXT NOT NULL,
            metadata TEXT
        );",
        )?;

        // Migration: add name column (ignore error if it already exists)
        let _ = conn.execute_batch("ALTER TABLE bonds ADD COLUMN name TEXT;");
        // Migration: add metadata column for older databases (ignore if it already exists).
        let _ = conn.execute_batch("ALTER TABLE bonds ADD COLUMN metadata TEXT;");

        Ok(Self { conn })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rusqlite::Connection;
    use std::collections::HashMap;
    use tempfile::TempDir; // Needed for metadata assertions

    /// Helper: creates a BondManager backed by in-memory SQLite.
    fn test_manager() -> BondManager {
        let conn = Connection::open_in_memory().unwrap();
        BondManager::from_connection(conn).unwrap()
    }

    /// Helper: creates a real temp directory that acts as a bond source.
    /// Returns (TempDir, PathBuf) -- hold onto TempDir so it doesn't drop.
    fn temp_source() -> (TempDir, PathBuf) {
        let dir = TempDir::new().unwrap();
        let path = dir.path().to_path_buf();
        (dir, path)
    }

    #[test]
    fn list_bonds_empty() {
        let mgr = test_manager();
        let bonds = mgr.list_bonds().unwrap();
        assert!(bonds.is_empty());
    }

    #[test]
    #[cfg_attr(windows, ignore)]
    fn create_and_get_bond() {
        let mgr = test_manager();
        let (_src_dir, src_path) = temp_source();
        let tgt_dir = TempDir::new().unwrap();
        let tgt_path = tgt_dir.path().join("link");

        let bond = mgr.create_bond(&src_path, &tgt_path, None).unwrap();

        // Verify it's in the DB
        let fetched = mgr.get_bond(&bond.id).unwrap();
        assert_eq!(fetched.id, bond.id);
        assert_eq!(fetched.source, src_path);
        assert_eq!(fetched.target, tgt_path);

        // Verify the symlink actually exists
        assert!(
            tgt_path
                .symlink_metadata()
                .unwrap()
                .file_type()
                .is_symlink()
        );
    }

    #[test]
    fn create_bond_nonexistent_source() {
        let mgr = test_manager();
        let result = mgr.create_bond("/no/such/path", "/tmp/whatever", None);
        assert!(matches!(result, Err(BondError::InvalidPath(_))));
    }

    #[test]
    #[cfg_attr(windows, ignore)]
    fn create_bond_target_already_exists() {
        let mgr = test_manager();
        let (_src_dir, src_path) = temp_source();
        let tgt_dir = TempDir::new().unwrap();
        let tgt_path = tgt_dir.path().join("occupied");

        // Create a non-empty directory at the target
        std::fs::create_dir(&tgt_path).unwrap();
        std::fs::write(tgt_path.join("file.txt"), "data").unwrap();

        let result = mgr.create_bond(&src_path, &tgt_path, None);
        assert!(matches!(result, Err(BondError::TargetExists(_))));
    }

    #[test]
    #[cfg_attr(windows, ignore)]
    fn delete_bond_removes_symlink() {
        let mgr = test_manager();
        let (_src_dir, src_path) = temp_source();
        let tgt_dir = TempDir::new().unwrap();
        let tgt_path = tgt_dir.path().join("link");

        let bond = mgr.create_bond(&src_path, &tgt_path, None).unwrap();
        assert!(tgt_path.exists());

        mgr.delete_bond(&bond.id, false).unwrap();
        assert!(!tgt_path.exists());

        // Also gone from DB
        assert!(matches!(
            mgr.get_bond(&bond.id),
            Err(BondError::NotFound(_))
        ));
    }

    #[test]
    fn delete_bond_not_found() {
        let mgr = test_manager();
        let result = mgr.delete_bond("nonexistent-id", false);
        assert!(matches!(result, Err(BondError::NotFound(_))));
    }

    #[test]
    #[cfg_attr(windows, ignore)]
    fn list_bonds_ordered_by_newest() {
        let mgr = test_manager();
        let (_src1, src1) = temp_source();
        let (_src2, src2) = temp_source();
        let tgt_dir = TempDir::new().unwrap();

        let bond1 = mgr
            .create_bond(&src1, tgt_dir.path().join("a"), None)
            .unwrap();
        let bond2 = mgr
            .create_bond(&src2, tgt_dir.path().join("b"), None)
            .unwrap();

        let bonds = mgr.list_bonds().unwrap();
        // bond2 was created second, should appear first (newest-first order)
        assert_eq!(bonds[0].id, bond2.id);
        assert_eq!(bonds[1].id, bond1.id);
    }

    #[test]
    #[cfg_attr(windows, ignore)]
    fn create_bond_with_metadata_round_trips() {
        let mgr = test_manager();
        let (_src_dir, src_path) = temp_source();
        let tgt_dir = TempDir::new().unwrap();
        let tgt_path = tgt_dir.path().join("link");

        let mut metadata = HashMap::new();
        metadata.insert("project".to_string(), "bonds".to_string());
        metadata.insert("owner".to_string(), "core-team".to_string());

        let created = mgr
            .create_bond_with_metadata(
                &src_path,
                &tgt_path,
                Some("meta-bond".into()),
                Some(metadata.clone()),
            )
            .unwrap();

        assert_eq!(created.metadata(), Some(&metadata));

        let fetched = mgr.get_bond(created.id()).unwrap();
        assert_eq!(fetched.metadata(), Some(&metadata));
    }

    #[test]
    #[cfg_attr(windows, ignore)]
    fn update_bond_metadata_set_and_clear() {
        let mgr = test_manager();
        let (_src_dir, src_path) = temp_source();
        let tgt_dir = TempDir::new().unwrap();
        let tgt_path = tgt_dir.path().join("link");

        let created = mgr.create_bond(&src_path, &tgt_path, None).unwrap();
        assert!(created.metadata().is_none());

        let mut metadata = HashMap::new();
        metadata.insert("env".to_string(), "dev".to_string());
        metadata.insert("team".to_string(), "platform".to_string());

        let updated = mgr
            .update_bond_metadata(created.id(), Some(metadata.clone()))
            .unwrap();
        assert_eq!(updated.metadata(), Some(&metadata));

        let fetched = mgr.get_bond(created.id()).unwrap();
        assert_eq!(fetched.metadata(), Some(&metadata));

        let cleared = mgr.update_bond_metadata(created.id(), None).unwrap();
        assert!(cleared.metadata().is_none());

        let fetched_again = mgr.get_bond(created.id()).unwrap();
        assert!(fetched_again.metadata().is_none());
    }
}