vapor_cli/
bookmarks.rs

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        // Use user's home directory for bookmarks file
30        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        // Load existing bookmarks, but don't fail if file doesn't exist
42        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        // Validate inputs
49        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        // Check for invalid characters in name
57        if name.contains(|c: char| c.is_control() || "\\/:*?\"<>|".contains(c)) {
58            anyhow::bail!("Bookmark name contains invalid characters");
59        }
60        
61        // Check if name is too long
62        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        // Create backup before saving
89        self.create_backup()?;
90        
91        // Use lock to prevent concurrent writes
92        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        // Create backup before deletion
137        self.create_backup()?;
138        
139        // Use lock to prevent concurrent writes
140        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        // Explicitly create the parent directory
173        fs::create_dir_all(parent_dir)
174            .with_context(|| format!("Failed to create bookmarks directory: {:?}", parent_dir))?;
175
176        // Create a named temporary file in the parent directory
177        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        // Write data to the temporary file
181        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        // Atomically replace the target file with the temporary file
186        temp_file.persist(&self.file_path)
187            .map_err(|e| {
188                // e is tempfile::PersistError, which contains the std::io::Error and the NamedTempFile.
189                // We are interested in the underlying io::Error for the message.
190                anyhow::anyhow!("Failed to save bookmarks file '{}' (source: {:?}, dest: {:?}): {}", 
191                                self.file_path.display(), 
192                                e.file.path(), // Path of the temporary file that failed to persist
193                                self.file_path, // Target path for persist
194                                e.error) // The std::io::Error
195            })?;
196
197        Ok(())
198    }
199
200    fn load_bookmarks(&mut self) -> Result<()> {
201        if !self.file_path.exists() {
202            return Ok(()); // No bookmarks file yet
203        }
204
205        let json_data = fs::read_to_string(&self.file_path)
206            .context("Failed to read bookmarks file")?;
207            
208        // Try to parse the JSON
209        match serde_json::from_str(&json_data) {
210            Ok(bookmarks) => {
211                self.bookmarks = bookmarks;
212                Ok(())
213            }
214            Err(e) => {
215                // If parsing fails, try to load from backup
216                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}