1use 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#[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#[derive(Clone)]
44pub struct BookmarkManager {
45 bookmarks: HashMap<String, Bookmark>,
46 file_path: PathBuf,
47 lock: Arc<Mutex<()>>,
48}
49
50impl BookmarkManager {
51 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 pub fn save_bookmark(
90 &mut self,
91 name: String,
92 query: String,
93 description: Option<String>,
94 ) -> Result<()> {
95 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 if name.contains(|c: char| c.is_control() || "\\/:*?\"<>|".contains(c)) {
105 anyhow::bail!("Bookmark name contains invalid characters");
106 }
107
108 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 self.create_backup()?;
137
138 let _lock = self.lock.lock().unwrap();
140
141 self.bookmarks.insert(name, bookmark);
142 self.save_bookmarks()?;
143 Ok(())
144 }
145
146 pub fn get_bookmark(&self, name: &str) -> Option<&Bookmark> {
156 self.bookmarks.get(name)
157 }
158
159 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 pub fn delete_bookmark(&mut self, name: &str) -> Result<bool> {
218 self.create_backup()?;
220
221 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 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 fs::create_dir_all(parent_dir)
272 .with_context(|| format!("Failed to create bookmarks directory: {:?}", parent_dir))?;
273
274 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 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 temp_file.persist(&self.file_path).map_err(|e| {
290 anyhow::anyhow!(
293 "Failed to save bookmarks file '{}' (source: {:?}, dest: {:?}): {}",
294 self.file_path.display(),
295 e.file.path(), self.file_path, e.error
298 ) })?;
300
301 Ok(())
302 }
303
304 fn load_bookmarks(&mut self) -> Result<()> {
305 if !self.file_path.exists() {
306 return Ok(()); }
308
309 let json_data =
310 fs::read_to_string(&self.file_path).context("Failed to read bookmarks file")?;
311
312 match serde_json::from_str(&json_data) {
314 Ok(bookmarks) => {
315 self.bookmarks = bookmarks;
316 Ok(())
317 }
318 Err(e) => {
319 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 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 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 let first_name = "first_bookmark".to_string();
459 manager.save_bookmark(first_name.clone(), "SELECT 1".to_string(), None)?;
460
461 let second_name = "second_bookmark".to_string();
463 manager.save_bookmark(second_name.clone(), "SELECT 2".to_string(), None)?;
464
465 fs::write(&manager.file_path, "invalid json")?;
467
468 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 assert!(recovered_manager.get_bookmark(&first_name).is_some());
479 assert!(recovered_manager.get_bookmark(&second_name).is_none());
480
481 Ok(())
482 }
483}