vapor-cli 0.0.5

A command-line interface for SQLite database management with enhanced features for data manipulation, querying, and testing
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
//! # SQL Query Bookmarking
//!
//! This module provides a robust system for managing user-defined SQL query bookmarks.
//! It allows users to save frequently used queries with a name and description, and then
//! easily recall and execute them.
//!
//! ## Features:
//! - **Persistent Storage**: Bookmarks are saved to a JSON file in the user's config directory.
//! - **CRUD Operations**: Supports creating, retrieving, listing, and deleting bookmarks.
//! - **Atomic Saves**: Uses temporary files and atomic move operations to prevent data corruption during saves.
//! - **Automatic Backups**: Creates a `.bak` file before any modification, allowing for recovery if the main file gets corrupted.
//! - **Concurrency Safe**: Uses a mutex to ensure that file write operations are thread-safe.
//! - **Data Validation**: Validates bookmark names and queries to prevent empty or invalid data.

use crate::config;
use anyhow::{Context, Result};
use prettytable::{row, Table};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use tempfile::NamedTempFile;

/// Represents a single saved SQL query bookmark.
///
/// This struct contains the details of a bookmark, including its name, the SQL query itself,
/// an optional description, and timestamps for creation and last modification.
#[derive(Serialize, Deserialize, Clone)]
pub struct Bookmark {
    pub name: String,
    pub query: String,
    pub description: Option<String>,
    pub created_at: String,
    pub last_modified: String,
}

/// Manages the collection of bookmarks, including loading from and saving to a file.
///
/// This struct is the main entry point for all bookmark-related operations. It holds the
/// bookmarks in a `HashMap` and manages the file I/O, including backups and atomic saves.
#[derive(Clone)]
pub struct BookmarkManager {
    bookmarks: HashMap<String, Bookmark>,
    file_path: PathBuf,
    lock: Arc<Mutex<()>>,
}

impl BookmarkManager {
        /// Creates a new `BookmarkManager` instance.
    ///
    /// This function initializes the manager by determining the path for the bookmarks file
    /// and loading any existing bookmarks from it. It will create the necessary directories
    /// if they don't exist.
    ///
    /// # Returns
    ///
    /// A `Result` containing the new `BookmarkManager` instance, or an `Err` if the bookmarks
    /// file cannot be read or parsed.
    pub fn new() -> Result<Self> {
        let file_path = config::get_bookmarks_path()?;
        let mut manager = Self {
            bookmarks: HashMap::new(),
            file_path,
            lock: Arc::new(Mutex::new(())),
        };
        manager
            .load_bookmarks()
            .with_context(|| "Failed to load bookmarks")?;
        Ok(manager)
    }

        /// Saves or updates a bookmark.
    ///
    /// This function adds a new bookmark or updates an existing one with the same name.
    /// It performs validation on the name and query, sets the timestamps, and then
    /// persists the entire bookmark collection to the file.
    ///
    /// # Arguments
    ///
    /// * `name` - The unique name for the bookmark.
    /// * `query` - The SQL query to be saved.
    /// * `description` - An optional description for the bookmark.
    ///
    /// # Returns
    ///
    /// A `Result` which is `Ok(())` on success, or an `Err` if validation or saving fails.
    pub fn save_bookmark(
        &mut self,
        name: String,
        query: String,
        description: Option<String>,
    ) -> Result<()> {
        // Validate inputs
        if name.trim().is_empty() {
            anyhow::bail!("Bookmark name cannot be empty");
        }
        if query.trim().is_empty() {
            anyhow::bail!("Bookmark query cannot be empty");
        }

        // Check for invalid characters in name
        if name.contains(|c: char| c.is_control() || "\\/:*?\"<>|".contains(c)) {
            anyhow::bail!("Bookmark name contains invalid characters");
        }

        // Check if name is too long
        if name.len() > 64 {
            anyhow::bail!("Bookmark name is too long (maximum 64 characters)");
        }

        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .context("System time error")?
            .as_secs();

        let timestamp = chrono::DateTime::from_timestamp(now as i64, 0)
            .context("Invalid timestamp")?
            .format("%Y-%m-%d %H:%M:%S UTC")
            .to_string();

        let bookmark = Bookmark {
            name: name.clone(),
            query,
            description,
            created_at: if let Some(existing) = self.bookmarks.get(&name) {
                existing.created_at.clone()
            } else {
                timestamp.clone()
            },
            last_modified: timestamp,
        };

        // Create backup before saving
        self.create_backup()?;

        // Use lock to prevent concurrent writes
        let _lock = self.lock.lock().unwrap();

        self.bookmarks.insert(name, bookmark);
        self.save_bookmarks()?;
        Ok(())
    }

        /// Retrieves a bookmark by its name.
    ///
    /// # Arguments
    ///
    /// * `name` - The name of the bookmark to retrieve.
    ///
    /// # Returns
    ///
    /// An `Option` containing a reference to the `Bookmark` if found, otherwise `None`.
    pub fn get_bookmark(&self, name: &str) -> Option<&Bookmark> {
        self.bookmarks.get(name)
    }

        /// Lists all saved bookmarks in a formatted table.
    ///
    /// This function prints a user-friendly table of all bookmarks to the console, including
    /// their name, description, timestamps, and a preview of the query.
    pub fn list_bookmarks(&self) {
        if self.bookmarks.is_empty() {
            println!("No bookmarks saved.");
            return;
        }

        let mut table = Table::new();
        table.set_format(*prettytable::format::consts::FORMAT_BOX_CHARS);
        table.add_row(row![
            "Name",
            "Description",
            "Created",
            "Modified",
            "Query Preview"
        ]);

        let mut bookmarks: Vec<_> = self.bookmarks.values().collect();
        bookmarks.sort_by(|a, b| a.name.cmp(&b.name));

        for bookmark in bookmarks {
            let description = bookmark
                .description
                .as_deref()
                .unwrap_or("(no description)");
            let query_preview = if bookmark.query.len() > 50 {
                format!("{}...", &bookmark.query[..47])
            } else {
                bookmark.query.clone()
            };
            table.add_row(row![
                bookmark.name,
                description,
                bookmark.created_at,
                bookmark.last_modified,
                query_preview
            ]);
        }

        table.printstd();
    }

        /// Deletes a bookmark by its name.
    ///
    /// This function removes a bookmark from the collection and then saves the updated
    /// collection to the file.
    ///
    /// # Arguments
    ///
    /// * `name` - The name of the bookmark to delete.
    ///
    /// # Returns
    ///
    /// A `Result` containing `true` if the bookmark was found and deleted, `false` if it
    /// was not found, or an `Err` if the save operation fails.
    pub fn delete_bookmark(&mut self, name: &str) -> Result<bool> {
        // Create backup before deletion
        self.create_backup()?;

        // Use lock to prevent concurrent writes
        let _lock = self.lock.lock().unwrap();

        if self.bookmarks.remove(name).is_some() {
            self.save_bookmarks()?;
            Ok(true)
        } else {
            Ok(false)
        }
    }

        /// Displays the full details of a single bookmark.
    ///
    /// This function prints all information about a specific bookmark to the console,
    /// including the full query.
    ///
    /// # Arguments
    ///
    /// * `name` - The name of the bookmark to show.
    ///
    /// # Returns
    ///
    /// `Some(())` if the bookmark was found and displayed, otherwise `None`.
    pub fn show_bookmark(&self, name: &str) -> Option<()> {
        if let Some(bookmark) = self.bookmarks.get(name) {
            println!("Bookmark: {}", bookmark.name);
            if let Some(desc) = &bookmark.description {
                println!("Description: {}", desc);
            }
            println!("Created: {}", bookmark.created_at);
            println!("Last Modified: {}", bookmark.last_modified);
            println!("Query:");
            println!("{}", bookmark.query);
            Some(())
        } else {
            None
        }
    }

    fn save_bookmarks(&self) -> Result<()> {
        let json_data = serde_json::to_string_pretty(&self.bookmarks)?;

        let parent_dir = self.file_path.parent().ok_or_else(|| {
            anyhow::anyhow!(
                "Bookmarks file path has no parent directory: {:?}",
                self.file_path
            )
        })?;

        // Explicitly create the parent directory
        fs::create_dir_all(parent_dir)
            .with_context(|| format!("Failed to create bookmarks directory: {:?}", parent_dir))?;

        // Create a named temporary file in the parent directory
        let mut temp_file = NamedTempFile::new_in(parent_dir).with_context(|| {
            format!(
                "Failed to create temporary bookmarks file in directory: {:?}",
                parent_dir
            )
        })?;

        // Write data to the temporary file
        use std::io::Write;
        temp_file
            .write_all(json_data.as_bytes())
            .context("Failed to write data to temporary bookmarks file")?;

        // Atomically replace the target file with the temporary file
        temp_file.persist(&self.file_path).map_err(|e| {
            // e is tempfile::PersistError, which contains the std::io::Error and the NamedTempFile.
            // We are interested in the underlying io::Error for the message.
            anyhow::anyhow!(
                "Failed to save bookmarks file '{}' (source: {:?}, dest: {:?}): {}",
                self.file_path.display(),
                e.file.path(),  // Path of the temporary file that failed to persist
                self.file_path, // Target path for persist
                e.error
            ) // The std::io::Error
        })?;

        Ok(())
    }

    fn load_bookmarks(&mut self) -> Result<()> {
        if !self.file_path.exists() {
            return Ok(()); // No bookmarks file yet
        }

        let json_data =
            fs::read_to_string(&self.file_path).context("Failed to read bookmarks file")?;

        // Try to parse the JSON
        match serde_json::from_str(&json_data) {
            Ok(bookmarks) => {
                self.bookmarks = bookmarks;
                Ok(())
            }
            Err(e) => {
                // If parsing fails, try to load from backup
                if let Ok(backup_data) = self.load_backup() {
                    self.bookmarks = serde_json::from_str(&backup_data)
                        .context("Failed to parse backup bookmarks file")?;
                    Ok(())
                } else {
                    Err(e).context("Failed to parse bookmarks file and no valid backup found")
                }
            }
        }
    }

    fn create_backup(&self) -> Result<()> {
        if !self.file_path.exists() {
            return Ok(());
        }

        let backup_path = self.file_path.with_extension("json.bak");
        fs::copy(&self.file_path, &backup_path).context("Failed to create bookmarks backup")?;
        Ok(())
    }

    fn load_backup(&self) -> Result<String> {
        let backup_path = self.file_path.with_extension("json.bak");
        fs::read_to_string(&backup_path).context("Failed to read bookmarks backup file")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::{tempdir, TempDir};

    // Helper to create a BookmarkManager in a temporary directory
    fn setup_test_manager() -> (BookmarkManager, TempDir) {
        let dir = tempdir().unwrap();
        let bookmarks_path = dir.path().join("bookmarks.json");
        let manager = BookmarkManager {
            bookmarks: HashMap::new(),
            file_path: bookmarks_path.clone(),
            lock: Arc::new(Mutex::new(())),
        };
        (manager, dir)
    }

    #[test]
    fn test_save_and_get_bookmark() -> Result<()> {
        let (mut manager, _dir) = setup_test_manager();

        let name = "test_bookmark".to_string();
        let query = "SELECT * FROM users".to_string();
        let description = Some("A test query".to_string());

        manager.save_bookmark(name.clone(), query.clone(), description.clone())?;

        let bookmark = manager.get_bookmark(&name).unwrap();
        assert_eq!(bookmark.name, name);
        assert_eq!(bookmark.query, query);
        assert_eq!(bookmark.description, description);

        Ok(())
    }

    #[test]
    fn test_update_bookmark() -> Result<()> {
        let (mut manager, _dir) = setup_test_manager();
        let name = "test_update".to_string();
        let initial_query = "SELECT 1".to_string();
        manager.save_bookmark(name.clone(), initial_query, None)?;

        let updated_query = "SELECT 2".to_string();
        manager.save_bookmark(
            name.clone(),
            updated_query.clone(),
            Some("Updated".to_string()),
        )?;

        let bookmark = manager.get_bookmark(&name).unwrap();
        assert_eq!(bookmark.query, updated_query);
        assert_eq!(bookmark.description, Some("Updated".to_string()));

        Ok(())
    }

    #[test]
    fn test_delete_bookmark() -> Result<()> {
        let (mut manager, _dir) = setup_test_manager();
        let name = "to_delete".to_string();
        manager.save_bookmark(name.clone(), "DELETE ME".to_string(), None)?;

        assert!(manager.get_bookmark(&name).is_some());
        manager.delete_bookmark(&name)?;
        assert!(manager.get_bookmark(&name).is_none());

        Ok(())
    }

    #[test]
    fn test_save_bookmark_invalid_name() {
        let (mut manager, _dir) = setup_test_manager();
        assert!(manager
            .save_bookmark("".to_string(), "q".to_string(), None)
            .is_err());
        assert!(manager
            .save_bookmark(" ".to_string(), "q".to_string(), None)
            .is_err());
        assert!(manager
            .save_bookmark("a/b".to_string(), "q".to_string(), None)
            .is_err());
    }

    #[test]
    fn test_persistence() -> Result<()> {
        let (mut manager, _dir) = setup_test_manager();
        let name = "persistent_bookmark".to_string();
        let query = "SELECT 'hello'".to_string();

        manager.save_bookmark(name.clone(), query.clone(), None)?;

        // Create a new manager instance that loads from the same file
        let mut new_manager = BookmarkManager {
            bookmarks: HashMap::new(),
            file_path: manager.file_path.clone(),
            lock: Arc::new(Mutex::new(())),
        };
        new_manager.load_bookmarks()?;

        let bookmark = new_manager.get_bookmark(&name).unwrap();
        assert_eq!(bookmark.name, name);
        assert_eq!(bookmark.query, query);

        Ok(())
    }

    #[test]
    fn test_backup_and_recovery() -> Result<()> {
        let (mut manager, _dir) = setup_test_manager();

        // Save a first bookmark. This creates bookmarks.json.
        let first_name = "first_bookmark".to_string();
        manager.save_bookmark(first_name.clone(), "SELECT 1".to_string(), None)?;

        // Save a second bookmark. This will create a backup of the file with only the first bookmark.
        let second_name = "second_bookmark".to_string();
        manager.save_bookmark(second_name.clone(), "SELECT 2".to_string(), None)?;

        // Now, corrupt the main bookmarks file (which contains both bookmarks).
        fs::write(&manager.file_path, "invalid json")?;

        // Try to load the bookmarks. It should recover from the backup.
        let mut recovered_manager = BookmarkManager {
            bookmarks: HashMap::new(),
            file_path: manager.file_path.clone(),
            lock: Arc::new(Mutex::new(())),
        };
        recovered_manager.load_bookmarks()?;

        // The recovered manager should have the state from the backup.
        // It should contain the first bookmark but not the second.
        assert!(recovered_manager.get_bookmark(&first_name).is_some());
        assert!(recovered_manager.get_bookmark(&second_name).is_none());

        Ok(())
    }
}