Skip to main content

saorsa_agent/session/
bookmark.rs

1//! Session bookmarking for quick access.
2
3use crate::SaorsaAgentError;
4use crate::session::SessionId;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::fs;
8use std::path::PathBuf;
9
10/// A bookmark mapping a name to a session ID.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Bookmark {
13    /// Bookmark name
14    pub name: String,
15    /// Session ID
16    pub session_id: SessionId,
17    /// When the bookmark was created
18    pub created: chrono::DateTime<chrono::Utc>,
19}
20
21/// Bookmark manager for persisting bookmarks.
22#[derive(Debug, Clone)]
23pub struct BookmarkManager {
24    bookmarks_path: PathBuf,
25}
26
27impl BookmarkManager {
28    /// Create a new bookmark manager with the default path.
29    pub fn new() -> Result<Self, SaorsaAgentError> {
30        let base = crate::session::path::sessions_dir()?;
31        let bookmarks_path = base
32            .parent()
33            .ok_or_else(|| SaorsaAgentError::Session("Invalid sessions directory".to_string()))?
34            .join("bookmarks.json");
35        Ok(Self { bookmarks_path })
36    }
37
38    /// Create a bookmark manager with a custom path (for testing).
39    pub fn with_path(path: PathBuf) -> Self {
40        Self {
41            bookmarks_path: path,
42        }
43    }
44
45    /// Load all bookmarks from disk.
46    fn load_bookmarks(&self) -> Result<HashMap<String, Bookmark>, SaorsaAgentError> {
47        if !self.bookmarks_path.exists() {
48            return Ok(HashMap::new());
49        }
50
51        let json = fs::read_to_string(&self.bookmarks_path).map_err(|e| {
52            SaorsaAgentError::Session(format!("Failed to read bookmarks file: {}", e))
53        })?;
54
55        serde_json::from_str(&json)
56            .map_err(|e| SaorsaAgentError::Session(format!("Failed to parse bookmarks: {}", e)))
57    }
58
59    /// Save bookmarks to disk.
60    fn save_bookmarks(
61        &self,
62        bookmarks: &HashMap<String, Bookmark>,
63    ) -> Result<(), SaorsaAgentError> {
64        // Ensure parent directory exists
65        if let Some(parent) = self.bookmarks_path.parent() {
66            crate::session::path::ensure_dir(parent)?;
67        }
68
69        let json = serde_json::to_string_pretty(bookmarks).map_err(|e| {
70            SaorsaAgentError::Session(format!("Failed to serialize bookmarks: {}", e))
71        })?;
72
73        fs::write(&self.bookmarks_path, json).map_err(|e| {
74            SaorsaAgentError::Session(format!("Failed to write bookmarks file: {}", e))
75        })?;
76
77        Ok(())
78    }
79
80    /// Add or update a bookmark.
81    pub fn add_bookmark(
82        &self,
83        name: String,
84        session_id: SessionId,
85    ) -> Result<(), SaorsaAgentError> {
86        let mut bookmarks = self.load_bookmarks()?;
87
88        bookmarks.insert(
89            name.clone(),
90            Bookmark {
91                name,
92                session_id,
93                created: chrono::Utc::now(),
94            },
95        );
96
97        self.save_bookmarks(&bookmarks)?;
98        Ok(())
99    }
100
101    /// Remove a bookmark.
102    pub fn remove_bookmark(&self, name: &str) -> Result<bool, SaorsaAgentError> {
103        let mut bookmarks = self.load_bookmarks()?;
104
105        let removed = bookmarks.remove(name).is_some();
106        if removed {
107            self.save_bookmarks(&bookmarks)?;
108        }
109
110        Ok(removed)
111    }
112
113    /// Rename a bookmark.
114    pub fn rename_bookmark(
115        &self,
116        old_name: &str,
117        new_name: String,
118    ) -> Result<(), SaorsaAgentError> {
119        let mut bookmarks = self.load_bookmarks()?;
120
121        let bookmark = bookmarks.remove(old_name).ok_or_else(|| {
122            SaorsaAgentError::Session(format!("Bookmark '{}' not found", old_name))
123        })?;
124
125        bookmarks.insert(
126            new_name.clone(),
127            Bookmark {
128                name: new_name,
129                ..bookmark
130            },
131        );
132
133        self.save_bookmarks(&bookmarks)?;
134        Ok(())
135    }
136
137    /// Get a bookmark by name.
138    pub fn get_bookmark(&self, name: &str) -> Result<Option<Bookmark>, SaorsaAgentError> {
139        let bookmarks = self.load_bookmarks()?;
140        Ok(bookmarks.get(name).cloned())
141    }
142
143    /// List all bookmarks, sorted by name.
144    pub fn list_bookmarks(&self) -> Result<Vec<Bookmark>, SaorsaAgentError> {
145        let bookmarks = self.load_bookmarks()?;
146        let mut list: Vec<Bookmark> = bookmarks.into_values().collect();
147        list.sort_by(|a, b| a.name.cmp(&b.name));
148        Ok(list)
149    }
150
151    /// Generate a unique auto-bookmark name.
152    pub fn generate_auto_name(&self) -> Result<String, SaorsaAgentError> {
153        let bookmarks = self.load_bookmarks()?;
154        let mut counter = 1;
155
156        loop {
157            let name = format!("bookmark-{}", counter);
158            if !bookmarks.contains_key(&name) {
159                return Ok(name);
160            }
161            counter += 1;
162            if counter > 10000 {
163                return Err(SaorsaAgentError::Session(
164                    "Could not generate unique bookmark name".to_string(),
165                ));
166            }
167        }
168    }
169}
170
171impl Default for BookmarkManager {
172    fn default() -> Self {
173        Self::new().unwrap_or_else(|_| Self::with_path(PathBuf::from("/tmp/saorsa-bookmarks.json")))
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use tempfile::TempDir;
181
182    fn test_manager() -> (TempDir, BookmarkManager) {
183        let temp_dir = match TempDir::new() {
184            Ok(dir) => dir,
185            Err(_) => panic!("Failed to create temp dir for test"),
186        };
187        let path = temp_dir.path().join("bookmarks.json");
188        let manager = BookmarkManager::with_path(path);
189        (temp_dir, manager)
190    }
191
192    #[test]
193    fn test_add_and_get_bookmark() {
194        let (_temp, manager) = test_manager();
195        let session_id = SessionId::new();
196
197        assert!(manager.add_bookmark("test".to_string(), session_id).is_ok());
198
199        let result = manager.get_bookmark("test");
200        assert!(result.is_ok());
201        match result {
202            Ok(Some(bookmark)) => {
203                assert!(bookmark.name == "test");
204                assert!(bookmark.session_id == session_id);
205            }
206            Ok(None) => panic!("Expected bookmark to exist"),
207            Err(_) => unreachable!(),
208        }
209    }
210
211    #[test]
212    fn test_remove_bookmark() {
213        let (_temp, manager) = test_manager();
214        let session_id = SessionId::new();
215
216        assert!(
217            manager
218                .add_bookmark("remove-me".to_string(), session_id)
219                .is_ok()
220        );
221
222        let removed = manager.remove_bookmark("remove-me");
223        assert!(removed.is_ok());
224        match removed {
225            Ok(true) => {}
226            Ok(false) => panic!("Expected bookmark to be removed"),
227            Err(_) => unreachable!(),
228        }
229
230        let result = manager.get_bookmark("remove-me");
231        assert!(result.is_ok());
232        match result {
233            Ok(None) => {}
234            Ok(Some(_)) => panic!("Expected bookmark to not exist"),
235            Err(_) => unreachable!(),
236        }
237    }
238
239    #[test]
240    fn test_rename_bookmark() {
241        let (_temp, manager) = test_manager();
242        let session_id = SessionId::new();
243
244        assert!(
245            manager
246                .add_bookmark("old-name".to_string(), session_id)
247                .is_ok()
248        );
249
250        let result = manager.rename_bookmark("old-name", "new-name".to_string());
251        assert!(result.is_ok());
252
253        // Old name should not exist
254        let old = manager.get_bookmark("old-name");
255        assert!(old.is_ok());
256        match old {
257            Ok(None) => {}
258            Ok(Some(_)) => panic!("Old bookmark should not exist"),
259            Err(_) => unreachable!(),
260        }
261
262        // New name should exist with same session ID
263        let new = manager.get_bookmark("new-name");
264        assert!(new.is_ok());
265        match new {
266            Ok(Some(bookmark)) => {
267                assert!(bookmark.session_id == session_id);
268            }
269            Ok(None) => panic!("New bookmark should exist"),
270            Err(_) => unreachable!(),
271        }
272    }
273
274    #[test]
275    fn test_list_bookmarks_sorted() {
276        let (_temp, manager) = test_manager();
277
278        let id1 = SessionId::new();
279        let id2 = SessionId::new();
280        let id3 = SessionId::new();
281
282        assert!(manager.add_bookmark("zebra".to_string(), id1).is_ok());
283        assert!(manager.add_bookmark("alpha".to_string(), id2).is_ok());
284        assert!(manager.add_bookmark("beta".to_string(), id3).is_ok());
285
286        let list = manager.list_bookmarks();
287        assert!(list.is_ok());
288        match list {
289            Ok(bookmarks) => {
290                assert!(bookmarks.len() == 3);
291                assert!(bookmarks[0].name == "alpha");
292                assert!(bookmarks[1].name == "beta");
293                assert!(bookmarks[2].name == "zebra");
294            }
295            Err(_) => unreachable!(),
296        }
297    }
298
299    #[test]
300    fn test_generate_auto_name() {
301        let (_temp, manager) = test_manager();
302
303        let name1 = manager.generate_auto_name();
304        assert!(name1.is_ok());
305        match name1 {
306            Ok(name) => {
307                assert!(name == "bookmark-1");
308
309                // Add it
310                assert!(manager.add_bookmark(name, SessionId::new()).is_ok());
311
312                // Generate next
313                let name2 = manager.generate_auto_name();
314                assert!(name2.is_ok());
315                match name2 {
316                    Ok(n) => assert!(n == "bookmark-2"),
317                    Err(_) => unreachable!(),
318                }
319            }
320            Err(_) => unreachable!(),
321        }
322    }
323}