1use anyhow::{Context, Result};
2use prettytable::{row, Table};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::PathBuf;
7use std::sync::{Arc, Mutex};
8use std::time::{SystemTime, UNIX_EPOCH};
9use tempfile::NamedTempFile;
10
11#[derive(Serialize, Deserialize, Clone)]
12pub struct Bookmark {
13 pub name: String,
14 pub query: String,
15 pub description: Option<String>,
16 pub created_at: String,
17 pub last_modified: String,
18}
19
20#[derive(Clone)]
21pub struct BookmarkManager {
22 bookmarks: HashMap<String, Bookmark>,
23 file_path: PathBuf,
24 lock: Arc<Mutex<()>>,
25}
26
27impl BookmarkManager {
28 pub fn new() -> Result<Self> {
29 let home_dir = dirs::home_dir()
31 .context("Could not find home directory")?;
32 let config_dir = home_dir.join(".vapor");
33 let file_path = config_dir.join("bookmarks.json");
34
35 let mut manager = Self {
36 bookmarks: HashMap::new(),
37 file_path,
38 lock: Arc::new(Mutex::new(())),
39 };
40
41 manager.load_bookmarks().with_context(|| "Failed to load bookmarks")?;
43
44 Ok(manager)
45 }
46
47 pub fn save_bookmark(&mut self, name: String, query: String, description: Option<String>) -> Result<()> {
48 if name.trim().is_empty() {
50 anyhow::bail!("Bookmark name cannot be empty");
51 }
52 if query.trim().is_empty() {
53 anyhow::bail!("Bookmark query cannot be empty");
54 }
55
56 if name.contains(|c: char| c.is_control() || "\\/:*?\"<>|".contains(c)) {
58 anyhow::bail!("Bookmark name contains invalid characters");
59 }
60
61 if name.len() > 64 {
63 anyhow::bail!("Bookmark name is too long (maximum 64 characters)");
64 }
65
66 let now = SystemTime::now()
67 .duration_since(UNIX_EPOCH)
68 .context("System time error")?
69 .as_secs();
70
71 let timestamp = chrono::DateTime::from_timestamp(now as i64, 0)
72 .context("Invalid timestamp")?
73 .format("%Y-%m-%d %H:%M:%S UTC")
74 .to_string();
75
76 let bookmark = Bookmark {
77 name: name.clone(),
78 query,
79 description,
80 created_at: if let Some(existing) = self.bookmarks.get(&name) {
81 existing.created_at.clone()
82 } else {
83 timestamp.clone()
84 },
85 last_modified: timestamp,
86 };
87
88 self.create_backup()?;
90
91 let _lock = self.lock.lock().unwrap();
93
94 self.bookmarks.insert(name, bookmark);
95 self.save_bookmarks()?;
96 Ok(())
97 }
98
99 pub fn get_bookmark(&self, name: &str) -> Option<&Bookmark> {
100 self.bookmarks.get(name)
101 }
102
103 pub fn list_bookmarks(&self) {
104 if self.bookmarks.is_empty() {
105 println!("No bookmarks saved.");
106 return;
107 }
108
109 let mut table = Table::new();
110 table.set_format(*prettytable::format::consts::FORMAT_BOX_CHARS);
111 table.add_row(row!["Name", "Description", "Created", "Modified", "Query Preview"]);
112
113 let mut bookmarks: Vec<_> = self.bookmarks.values().collect();
114 bookmarks.sort_by(|a, b| a.name.cmp(&b.name));
115
116 for bookmark in bookmarks {
117 let description = bookmark.description.as_deref().unwrap_or("(no description)");
118 let query_preview = if bookmark.query.len() > 50 {
119 format!("{}...", &bookmark.query[..47])
120 } else {
121 bookmark.query.clone()
122 };
123 table.add_row(row![
124 bookmark.name,
125 description,
126 bookmark.created_at,
127 bookmark.last_modified,
128 query_preview
129 ]);
130 }
131
132 table.printstd();
133 }
134
135 pub fn delete_bookmark(&mut self, name: &str) -> Result<bool> {
136 self.create_backup()?;
138
139 let _lock = self.lock.lock().unwrap();
141
142 if self.bookmarks.remove(name).is_some() {
143 self.save_bookmarks()?;
144 Ok(true)
145 } else {
146 Ok(false)
147 }
148 }
149
150 pub fn show_bookmark(&self, name: &str) -> Option<()> {
151 if let Some(bookmark) = self.bookmarks.get(name) {
152 println!("Bookmark: {}", bookmark.name);
153 if let Some(desc) = &bookmark.description {
154 println!("Description: {}", desc);
155 }
156 println!("Created: {}", bookmark.created_at);
157 println!("Last Modified: {}", bookmark.last_modified);
158 println!("Query:");
159 println!("{}", bookmark.query);
160 Some(())
161 } else {
162 None
163 }
164 }
165
166 fn save_bookmarks(&self) -> Result<()> {
167 let json_data = serde_json::to_string_pretty(&self.bookmarks)?;
168
169 let parent_dir = self.file_path.parent()
170 .ok_or_else(|| anyhow::anyhow!("Bookmarks file path has no parent directory: {:?}", self.file_path))?;
171
172 fs::create_dir_all(parent_dir)
174 .with_context(|| format!("Failed to create bookmarks directory: {:?}", parent_dir))?;
175
176 let mut temp_file = NamedTempFile::new_in(parent_dir)
178 .with_context(|| format!("Failed to create temporary bookmarks file in directory: {:?}", parent_dir))?;
179
180 use std::io::Write;
182 temp_file.write_all(json_data.as_bytes())
183 .context("Failed to write data to temporary bookmarks file")?;
184
185 temp_file.persist(&self.file_path)
187 .map_err(|e| {
188 anyhow::anyhow!("Failed to save bookmarks file '{}' (source: {:?}, dest: {:?}): {}",
191 self.file_path.display(),
192 e.file.path(), self.file_path, e.error) })?;
196
197 Ok(())
198 }
199
200 fn load_bookmarks(&mut self) -> Result<()> {
201 if !self.file_path.exists() {
202 return Ok(()); }
204
205 let json_data = fs::read_to_string(&self.file_path)
206 .context("Failed to read bookmarks file")?;
207
208 match serde_json::from_str(&json_data) {
210 Ok(bookmarks) => {
211 self.bookmarks = bookmarks;
212 Ok(())
213 }
214 Err(e) => {
215 if let Ok(backup_data) = self.load_backup() {
217 self.bookmarks = serde_json::from_str(&backup_data)
218 .context("Failed to parse backup bookmarks file")?;
219 Ok(())
220 } else {
221 Err(e).context("Failed to parse bookmarks file and no valid backup found")
222 }
223 }
224 }
225 }
226
227 fn create_backup(&self) -> Result<()> {
228 if !self.file_path.exists() {
229 return Ok(());
230 }
231
232 let backup_path = self.file_path.with_extension("json.bak");
233 fs::copy(&self.file_path, &backup_path)
234 .context("Failed to create bookmarks backup")?;
235 Ok(())
236 }
237
238 fn load_backup(&self) -> Result<String> {
239 let backup_path = self.file_path.with_extension("json.bak");
240 fs::read_to_string(&backup_path)
241 .context("Failed to read bookmarks backup file")
242 }
243}