vapor_cli/
bookmarks.rs

1//! # SQL Query Bookmarking
2//!
3//! This module provides a robust system for managing user-defined SQL query bookmarks.
4//! It allows users to save frequently used queries with a name and description, and then
5//! easily recall and execute them.
6//!
7//! ## Features:
8//! - **Persistent Storage**: Bookmarks are saved to a JSON file in the user's config directory.
9//! - **CRUD Operations**: Supports creating, retrieving, listing, and deleting bookmarks.
10//! - **Atomic Saves**: Uses temporary files and atomic move operations to prevent data corruption during saves.
11//! - **Automatic Backups**: Creates a `.bak` file before any modification, allowing for recovery if the main file gets corrupted.
12//! - **Concurrency Safe**: Uses a mutex to ensure that file write operations are thread-safe.
13//! - **Data Validation**: Validates bookmark names and queries to prevent empty or invalid data.
14
15use crate::config;
16use anyhow::{Context, Result};
17use prettytable::{row, Table};
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20use std::fs;
21use std::path::PathBuf;
22use std::sync::{Arc, Mutex};
23use std::time::{SystemTime, UNIX_EPOCH};
24use tempfile::NamedTempFile;
25
26/// Represents a single saved SQL query bookmark.
27///
28/// This struct contains the details of a bookmark, including its name, the SQL query itself,
29/// an optional description, and timestamps for creation and last modification.
30#[derive(Serialize, Deserialize, Clone)]
31pub struct Bookmark {
32    pub name: String,
33    pub query: String,
34    pub description: Option<String>,
35    pub created_at: String,
36    pub last_modified: String,
37}
38
39/// Manages the collection of bookmarks, including loading from and saving to a file.
40///
41/// This struct is the main entry point for all bookmark-related operations. It holds the
42/// bookmarks in a `HashMap` and manages the file I/O, including backups and atomic saves.
43#[derive(Clone)]
44pub struct BookmarkManager {
45    bookmarks: HashMap<String, Bookmark>,
46    file_path: PathBuf,
47    lock: Arc<Mutex<()>>,
48}
49
50impl BookmarkManager {
51        /// Creates a new `BookmarkManager` instance.
52    ///
53    /// This function initializes the manager by determining the path for the bookmarks file
54    /// and loading any existing bookmarks from it. It will create the necessary directories
55    /// if they don't exist.
56    ///
57    /// # Returns
58    ///
59    /// A `Result` containing the new `BookmarkManager` instance, or an `Err` if the bookmarks
60    /// file cannot be read or parsed.
61    pub fn new() -> Result<Self> {
62        let file_path = config::get_bookmarks_path()?;
63        let mut manager = Self {
64            bookmarks: HashMap::new(),
65            file_path,
66            lock: Arc::new(Mutex::new(())),
67        };
68        manager
69            .load_bookmarks()
70            .with_context(|| "Failed to load bookmarks")?;
71        Ok(manager)
72    }
73
74        /// Saves or updates a bookmark.
75    ///
76    /// This function adds a new bookmark or updates an existing one with the same name.
77    /// It performs validation on the name and query, sets the timestamps, and then
78    /// persists the entire bookmark collection to the file.
79    ///
80    /// # Arguments
81    ///
82    /// * `name` - The unique name for the bookmark.
83    /// * `query` - The SQL query to be saved.
84    /// * `description` - An optional description for the bookmark.
85    ///
86    /// # Returns
87    ///
88    /// A `Result` which is `Ok(())` on success, or an `Err` if validation or saving fails.
89    pub fn save_bookmark(
90        &mut self,
91        name: String,
92        query: String,
93        description: Option<String>,
94    ) -> Result<()> {
95        // Validate inputs
96        if name.trim().is_empty() {
97            anyhow::bail!("Bookmark name cannot be empty");
98        }
99        if query.trim().is_empty() {
100            anyhow::bail!("Bookmark query cannot be empty");
101        }
102
103        // Check for invalid characters in name
104        if name.contains(|c: char| c.is_control() || "\\/:*?\"<>|".contains(c)) {
105            anyhow::bail!("Bookmark name contains invalid characters");
106        }
107
108        // Check if name is too long
109        if name.len() > 64 {
110            anyhow::bail!("Bookmark name is too long (maximum 64 characters)");
111        }
112
113        let now = SystemTime::now()
114            .duration_since(UNIX_EPOCH)
115            .context("System time error")?
116            .as_secs();
117
118        let timestamp = chrono::DateTime::from_timestamp(now as i64, 0)
119            .context("Invalid timestamp")?
120            .format("%Y-%m-%d %H:%M:%S UTC")
121            .to_string();
122
123        let bookmark = Bookmark {
124            name: name.clone(),
125            query,
126            description,
127            created_at: if let Some(existing) = self.bookmarks.get(&name) {
128                existing.created_at.clone()
129            } else {
130                timestamp.clone()
131            },
132            last_modified: timestamp,
133        };
134
135        // Create backup before saving
136        self.create_backup()?;
137
138        // Use lock to prevent concurrent writes
139        let _lock = self.lock.lock().unwrap();
140
141        self.bookmarks.insert(name, bookmark);
142        self.save_bookmarks()?;
143        Ok(())
144    }
145
146        /// Retrieves a bookmark by its name.
147    ///
148    /// # Arguments
149    ///
150    /// * `name` - The name of the bookmark to retrieve.
151    ///
152    /// # Returns
153    ///
154    /// An `Option` containing a reference to the `Bookmark` if found, otherwise `None`.
155    pub fn get_bookmark(&self, name: &str) -> Option<&Bookmark> {
156        self.bookmarks.get(name)
157    }
158
159        /// Lists all saved bookmarks in a formatted table.
160    ///
161    /// This function prints a user-friendly table of all bookmarks to the console, including
162    /// their name, description, timestamps, and a preview of the query.
163    pub fn list_bookmarks(&self) {
164        if self.bookmarks.is_empty() {
165            println!("No bookmarks saved.");
166            return;
167        }
168
169        let mut table = Table::new();
170        table.set_format(*prettytable::format::consts::FORMAT_BOX_CHARS);
171        table.add_row(row![
172            "Name",
173            "Description",
174            "Created",
175            "Modified",
176            "Query Preview"
177        ]);
178
179        let mut bookmarks: Vec<_> = self.bookmarks.values().collect();
180        bookmarks.sort_by(|a, b| a.name.cmp(&b.name));
181
182        for bookmark in bookmarks {
183            let description = bookmark
184                .description
185                .as_deref()
186                .unwrap_or("(no description)");
187            let query_preview = if bookmark.query.len() > 50 {
188                format!("{}...", &bookmark.query[..47])
189            } else {
190                bookmark.query.clone()
191            };
192            table.add_row(row![
193                bookmark.name,
194                description,
195                bookmark.created_at,
196                bookmark.last_modified,
197                query_preview
198            ]);
199        }
200
201        table.printstd();
202    }
203
204        /// Deletes a bookmark by its name.
205    ///
206    /// This function removes a bookmark from the collection and then saves the updated
207    /// collection to the file.
208    ///
209    /// # Arguments
210    ///
211    /// * `name` - The name of the bookmark to delete.
212    ///
213    /// # Returns
214    ///
215    /// A `Result` containing `true` if the bookmark was found and deleted, `false` if it
216    /// was not found, or an `Err` if the save operation fails.
217    pub fn delete_bookmark(&mut self, name: &str) -> Result<bool> {
218        // Create backup before deletion
219        self.create_backup()?;
220
221        // Use lock to prevent concurrent writes
222        let _lock = self.lock.lock().unwrap();
223
224        if self.bookmarks.remove(name).is_some() {
225            self.save_bookmarks()?;
226            Ok(true)
227        } else {
228            Ok(false)
229        }
230    }
231
232        /// Displays the full details of a single bookmark.
233    ///
234    /// This function prints all information about a specific bookmark to the console,
235    /// including the full query.
236    ///
237    /// # Arguments
238    ///
239    /// * `name` - The name of the bookmark to show.
240    ///
241    /// # Returns
242    ///
243    /// `Some(())` if the bookmark was found and displayed, otherwise `None`.
244    pub fn show_bookmark(&self, name: &str) -> Option<()> {
245        if let Some(bookmark) = self.bookmarks.get(name) {
246            println!("Bookmark: {}", bookmark.name);
247            if let Some(desc) = &bookmark.description {
248                println!("Description: {}", desc);
249            }
250            println!("Created: {}", bookmark.created_at);
251            println!("Last Modified: {}", bookmark.last_modified);
252            println!("Query:");
253            println!("{}", bookmark.query);
254            Some(())
255        } else {
256            None
257        }
258    }
259
260    fn save_bookmarks(&self) -> Result<()> {
261        let json_data = serde_json::to_string_pretty(&self.bookmarks)?;
262
263        let parent_dir = self.file_path.parent().ok_or_else(|| {
264            anyhow::anyhow!(
265                "Bookmarks file path has no parent directory: {:?}",
266                self.file_path
267            )
268        })?;
269
270        // Explicitly create the parent directory
271        fs::create_dir_all(parent_dir)
272            .with_context(|| format!("Failed to create bookmarks directory: {:?}", parent_dir))?;
273
274        // Create a named temporary file in the parent directory
275        let mut temp_file = NamedTempFile::new_in(parent_dir).with_context(|| {
276            format!(
277                "Failed to create temporary bookmarks file in directory: {:?}",
278                parent_dir
279            )
280        })?;
281
282        // Write data to the temporary file
283        use std::io::Write;
284        temp_file
285            .write_all(json_data.as_bytes())
286            .context("Failed to write data to temporary bookmarks file")?;
287
288        // Atomically replace the target file with the temporary file
289        temp_file.persist(&self.file_path).map_err(|e| {
290            // e is tempfile::PersistError, which contains the std::io::Error and the NamedTempFile.
291            // We are interested in the underlying io::Error for the message.
292            anyhow::anyhow!(
293                "Failed to save bookmarks file '{}' (source: {:?}, dest: {:?}): {}",
294                self.file_path.display(),
295                e.file.path(),  // Path of the temporary file that failed to persist
296                self.file_path, // Target path for persist
297                e.error
298            ) // The std::io::Error
299        })?;
300
301        Ok(())
302    }
303
304    fn load_bookmarks(&mut self) -> Result<()> {
305        if !self.file_path.exists() {
306            return Ok(()); // No bookmarks file yet
307        }
308
309        let json_data =
310            fs::read_to_string(&self.file_path).context("Failed to read bookmarks file")?;
311
312        // Try to parse the JSON
313        match serde_json::from_str(&json_data) {
314            Ok(bookmarks) => {
315                self.bookmarks = bookmarks;
316                Ok(())
317            }
318            Err(e) => {
319                // If parsing fails, try to load from backup
320                if let Ok(backup_data) = self.load_backup() {
321                    self.bookmarks = serde_json::from_str(&backup_data)
322                        .context("Failed to parse backup bookmarks file")?;
323                    Ok(())
324                } else {
325                    Err(e).context("Failed to parse bookmarks file and no valid backup found")
326                }
327            }
328        }
329    }
330
331    fn create_backup(&self) -> Result<()> {
332        if !self.file_path.exists() {
333            return Ok(());
334        }
335
336        let backup_path = self.file_path.with_extension("json.bak");
337        fs::copy(&self.file_path, &backup_path).context("Failed to create bookmarks backup")?;
338        Ok(())
339    }
340
341    fn load_backup(&self) -> Result<String> {
342        let backup_path = self.file_path.with_extension("json.bak");
343        fs::read_to_string(&backup_path).context("Failed to read bookmarks backup file")
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use tempfile::{tempdir, TempDir};
351
352    // Helper to create a BookmarkManager in a temporary directory
353    fn setup_test_manager() -> (BookmarkManager, TempDir) {
354        let dir = tempdir().unwrap();
355        let bookmarks_path = dir.path().join("bookmarks.json");
356        let manager = BookmarkManager {
357            bookmarks: HashMap::new(),
358            file_path: bookmarks_path.clone(),
359            lock: Arc::new(Mutex::new(())),
360        };
361        (manager, dir)
362    }
363
364    #[test]
365    fn test_save_and_get_bookmark() -> Result<()> {
366        let (mut manager, _dir) = setup_test_manager();
367
368        let name = "test_bookmark".to_string();
369        let query = "SELECT * FROM users".to_string();
370        let description = Some("A test query".to_string());
371
372        manager.save_bookmark(name.clone(), query.clone(), description.clone())?;
373
374        let bookmark = manager.get_bookmark(&name).unwrap();
375        assert_eq!(bookmark.name, name);
376        assert_eq!(bookmark.query, query);
377        assert_eq!(bookmark.description, description);
378
379        Ok(())
380    }
381
382    #[test]
383    fn test_update_bookmark() -> Result<()> {
384        let (mut manager, _dir) = setup_test_manager();
385        let name = "test_update".to_string();
386        let initial_query = "SELECT 1".to_string();
387        manager.save_bookmark(name.clone(), initial_query, None)?;
388
389        let updated_query = "SELECT 2".to_string();
390        manager.save_bookmark(
391            name.clone(),
392            updated_query.clone(),
393            Some("Updated".to_string()),
394        )?;
395
396        let bookmark = manager.get_bookmark(&name).unwrap();
397        assert_eq!(bookmark.query, updated_query);
398        assert_eq!(bookmark.description, Some("Updated".to_string()));
399
400        Ok(())
401    }
402
403    #[test]
404    fn test_delete_bookmark() -> Result<()> {
405        let (mut manager, _dir) = setup_test_manager();
406        let name = "to_delete".to_string();
407        manager.save_bookmark(name.clone(), "DELETE ME".to_string(), None)?;
408
409        assert!(manager.get_bookmark(&name).is_some());
410        manager.delete_bookmark(&name)?;
411        assert!(manager.get_bookmark(&name).is_none());
412
413        Ok(())
414    }
415
416    #[test]
417    fn test_save_bookmark_invalid_name() {
418        let (mut manager, _dir) = setup_test_manager();
419        assert!(manager
420            .save_bookmark("".to_string(), "q".to_string(), None)
421            .is_err());
422        assert!(manager
423            .save_bookmark(" ".to_string(), "q".to_string(), None)
424            .is_err());
425        assert!(manager
426            .save_bookmark("a/b".to_string(), "q".to_string(), None)
427            .is_err());
428    }
429
430    #[test]
431    fn test_persistence() -> Result<()> {
432        let (mut manager, _dir) = setup_test_manager();
433        let name = "persistent_bookmark".to_string();
434        let query = "SELECT 'hello'".to_string();
435
436        manager.save_bookmark(name.clone(), query.clone(), None)?;
437
438        // Create a new manager instance that loads from the same file
439        let mut new_manager = BookmarkManager {
440            bookmarks: HashMap::new(),
441            file_path: manager.file_path.clone(),
442            lock: Arc::new(Mutex::new(())),
443        };
444        new_manager.load_bookmarks()?;
445
446        let bookmark = new_manager.get_bookmark(&name).unwrap();
447        assert_eq!(bookmark.name, name);
448        assert_eq!(bookmark.query, query);
449
450        Ok(())
451    }
452
453    #[test]
454    fn test_backup_and_recovery() -> Result<()> {
455        let (mut manager, _dir) = setup_test_manager();
456
457        // Save a first bookmark. This creates bookmarks.json.
458        let first_name = "first_bookmark".to_string();
459        manager.save_bookmark(first_name.clone(), "SELECT 1".to_string(), None)?;
460
461        // Save a second bookmark. This will create a backup of the file with only the first bookmark.
462        let second_name = "second_bookmark".to_string();
463        manager.save_bookmark(second_name.clone(), "SELECT 2".to_string(), None)?;
464
465        // Now, corrupt the main bookmarks file (which contains both bookmarks).
466        fs::write(&manager.file_path, "invalid json")?;
467
468        // Try to load the bookmarks. It should recover from the backup.
469        let mut recovered_manager = BookmarkManager {
470            bookmarks: HashMap::new(),
471            file_path: manager.file_path.clone(),
472            lock: Arc::new(Mutex::new(())),
473        };
474        recovered_manager.load_bookmarks()?;
475
476        // The recovered manager should have the state from the backup.
477        // It should contain the first bookmark but not the second.
478        assert!(recovered_manager.get_bookmark(&first_name).is_some());
479        assert!(recovered_manager.get_bookmark(&second_name).is_none());
480
481        Ok(())
482    }
483}