1use std::{
22 collections::VecDeque,
23 path::{Path, PathBuf},
24 sync::Arc,
25};
26
27use serde::{Deserialize, Serialize};
28use tokio::sync::RwLock;
29use uuid::Uuid;
30
31use crate::{
32 error::AppError,
33 ids::{BucketId, ProfileId},
34};
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct Bookmark {
50 pub id: String,
52 pub profile_id: ProfileId,
54 pub bucket: BucketId,
56 pub prefix: String,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub label: Option<String>,
61 pub created_at: i64,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, Default)]
69#[serde(rename_all = "camelCase")]
70pub struct BookmarkPatch {
71 pub label: Option<String>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct BookmarkInput {
78 pub profile_id: ProfileId,
79 pub bucket: BucketId,
80 pub prefix: String,
81 pub label: Option<String>,
82}
83
84#[derive(Debug, Default, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct BookmarkStore {
96 bookmarks: Vec<Bookmark>,
97}
98
99impl BookmarkStore {
100 pub fn load(path: &Path) -> Result<Self, AppError> {
102 if !path.exists() {
103 return Ok(Self::default());
104 }
105 let raw = std::fs::read_to_string(path).map_err(|e| AppError::Internal {
106 trace_id: format!("bookmarks_load_read: {e}"),
107 })?;
108 serde_json::from_str(&raw).map_err(|e| AppError::Internal {
109 trace_id: format!("bookmarks_load_parse: {e}"),
110 })
111 }
112
113 fn save_to(&self, path: &Path) -> Result<(), AppError> {
115 let json = serde_json::to_string_pretty(self).map_err(|e| AppError::Internal {
116 trace_id: format!("bookmarks_save_serialize: {e}"),
117 })?;
118 if let Some(parent) = path.parent() {
119 std::fs::create_dir_all(parent).map_err(|e| AppError::Internal {
120 trace_id: format!("bookmarks_save_mkdir: {e}"),
121 })?;
122 }
123 let tmp = path.with_extension("json.tmp");
124 std::fs::write(&tmp, json.as_bytes()).map_err(|e| AppError::Internal {
125 trace_id: format!("bookmarks_save_write: {e}"),
126 })?;
127 std::fs::rename(&tmp, path).map_err(|e| AppError::Internal {
128 trace_id: format!("bookmarks_save_rename: {e}"),
129 })?;
130 Ok(())
131 }
132
133 pub fn list(&self) -> Vec<Bookmark> {
135 self.bookmarks.clone()
136 }
137
138 pub fn add(&mut self, input: BookmarkInput, path: &Path) -> Result<Bookmark, AppError> {
140 let bookmark = Bookmark {
141 id: Uuid::new_v4().to_string(),
142 profile_id: input.profile_id,
143 bucket: input.bucket,
144 prefix: input.prefix,
145 label: input.label,
146 created_at: unix_ms_now(),
147 };
148 self.bookmarks.push(bookmark.clone());
149 self.save_to(path)?;
150 Ok(bookmark)
151 }
152
153 pub fn remove(&mut self, id: &str, path: &Path) -> Result<bool, AppError> {
155 let before = self.bookmarks.len();
156 self.bookmarks.retain(|b| b.id != id);
157 let removed = self.bookmarks.len() < before;
158 if removed {
159 self.save_to(path)?;
160 }
161 Ok(removed)
162 }
163
164 pub fn update(
167 &mut self,
168 id: &str,
169 patch: BookmarkPatch,
170 path: &Path,
171 ) -> Result<Bookmark, AppError> {
172 let bm = self
173 .bookmarks
174 .iter_mut()
175 .find(|b| b.id == id)
176 .ok_or_else(|| AppError::NotFound {
177 resource: format!("bookmark:{id}"),
178 })?;
179
180 if let Some(label) = patch.label {
181 bm.label = Some(label);
182 }
183
184 let result = bm.clone();
185 self.save_to(path)?;
186 Ok(result)
187 }
188}
189
190pub type BookmarkStoreHandle = Arc<RwLock<BookmarkStoreState>>;
192
193pub struct BookmarkStoreState {
195 pub store: BookmarkStore,
196 pub path: PathBuf,
197}
198
199impl BookmarkStoreState {
200 pub fn new(store: BookmarkStore, path: PathBuf) -> Self {
201 Self { store, path }
202 }
203}
204
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase")]
212pub struct RecentLocation {
213 pub profile_id: ProfileId,
214 pub bucket: BucketId,
215 pub prefix: String,
216 pub visited_at: i64,
218}
219
220pub struct RecentsStore {
231 buffer: VecDeque<RecentLocation>,
232 cap: usize,
234 pub path: PathBuf,
235}
236
237impl RecentsStore {
238 const DEFAULT_CAP: usize = 50;
239
240 pub fn new(path: PathBuf) -> Self {
241 Self {
242 buffer: VecDeque::new(),
243 cap: Self::DEFAULT_CAP,
244 path,
245 }
246 }
247
248 pub fn load(path: PathBuf) -> Self {
250 let buffer = if path.exists() {
251 std::fs::read_to_string(&path)
252 .ok()
253 .and_then(|raw| serde_json::from_str::<Vec<RecentLocation>>(&raw).ok())
254 .map(|v| v.into_iter().collect::<VecDeque<_>>())
255 .unwrap_or_default()
256 } else {
257 VecDeque::new()
258 };
259 let mut store = Self {
262 buffer,
263 cap: Self::DEFAULT_CAP,
264 path,
265 };
266 store.clamp();
267 store
268 }
269
270 fn clamp(&mut self) {
271 while self.buffer.len() > self.cap {
272 self.buffer.pop_back();
273 }
274 }
275
276 pub fn track(&mut self, profile_id: ProfileId, bucket: BucketId, prefix: String) {
279 if let Some(front) = self.buffer.front_mut() {
281 if front.profile_id == profile_id && front.bucket == bucket && front.prefix == prefix {
282 front.visited_at = unix_ms_now();
283 return;
284 }
285 }
286
287 self.buffer
290 .retain(|r| !(r.profile_id == profile_id && r.bucket == bucket && r.prefix == prefix));
291
292 if self.buffer.len() >= self.cap {
293 self.buffer.pop_back();
294 }
295 self.buffer.push_front(RecentLocation {
296 profile_id,
297 bucket,
298 prefix,
299 visited_at: unix_ms_now(),
300 });
301 }
302
303 pub fn list(&self) -> Vec<RecentLocation> {
305 self.buffer.iter().cloned().collect()
306 }
307
308 pub fn clear(&mut self) {
310 self.buffer.clear();
311 }
312
313 pub fn flush(&self) -> Result<(), AppError> {
321 let entries: Vec<&RecentLocation> = self.buffer.iter().collect();
322 let json = serde_json::to_string_pretty(&entries).map_err(|e| AppError::Internal {
323 trace_id: format!("recents_flush_serialize: {e}"),
324 })?;
325 if let Some(parent) = self.path.parent() {
326 std::fs::create_dir_all(parent).map_err(|e| AppError::Internal {
327 trace_id: format!("recents_flush_mkdir: {e}"),
328 })?;
329 }
330 let tmp = self.path.with_extension("json.tmp");
331 std::fs::write(&tmp, json.as_bytes()).map_err(|e| AppError::Internal {
332 trace_id: format!("recents_flush_write: {e}"),
333 })?;
334 std::fs::rename(&tmp, &self.path).map_err(|e| AppError::Internal {
335 trace_id: format!("recents_flush_rename: {e}"),
336 })?;
337 Ok(())
338 }
339}
340
341pub type RecentsHandle = Arc<RwLock<RecentsStore>>;
343
344fn unix_ms_now() -> i64 {
349 std::time::SystemTime::now()
350 .duration_since(std::time::UNIX_EPOCH)
351 .map(|d| d.as_millis() as i64)
352 .unwrap_or(0)
353}
354
355#[cfg(test)]
360mod tests {
361 use super::*;
362 use tempfile::tempdir;
363
364 fn pid(s: &str) -> ProfileId {
365 ProfileId::new(s)
366 }
367 fn bid(s: &str) -> BucketId {
368 BucketId::new(s)
369 }
370
371 #[test]
376 fn bookmark_add_and_list() {
377 let dir = tempdir().unwrap();
378 let path = dir.path().join("bookmarks.json");
379 let mut store = BookmarkStore::load(&path).unwrap();
380
381 let bm = store
382 .add(
383 BookmarkInput {
384 profile_id: pid("p1"),
385 bucket: bid("bucket-a"),
386 prefix: "folder/".to_string(),
387 label: Some("My folder".to_string()),
388 },
389 &path,
390 )
391 .unwrap();
392
393 assert!(!bm.id.is_empty());
394 assert_eq!(bm.profile_id.as_str(), "p1");
395 assert_eq!(bm.bucket.as_str(), "bucket-a");
396 assert_eq!(bm.prefix, "folder/");
397 assert_eq!(bm.label.as_deref(), Some("My folder"));
398
399 let list = store.list();
400 assert_eq!(list.len(), 1);
401 assert_eq!(list[0].id, bm.id);
402 }
403
404 #[test]
405 fn bookmark_remove_returns_true_on_success() {
406 let dir = tempdir().unwrap();
407 let path = dir.path().join("bookmarks.json");
408 let mut store = BookmarkStore::load(&path).unwrap();
409
410 let bm = store
411 .add(
412 BookmarkInput {
413 profile_id: pid("p1"),
414 bucket: bid("b"),
415 prefix: "".to_string(),
416 label: None,
417 },
418 &path,
419 )
420 .unwrap();
421
422 let removed = store.remove(&bm.id, &path).unwrap();
423 assert!(removed);
424 assert!(store.list().is_empty());
425 }
426
427 #[test]
428 fn bookmark_remove_missing_id_returns_false() {
429 let dir = tempdir().unwrap();
430 let path = dir.path().join("bookmarks.json");
431 let mut store = BookmarkStore::load(&path).unwrap();
432 let removed = store.remove("no-such-id", &path).unwrap();
433 assert!(!removed);
434 }
435
436 #[test]
437 fn bookmark_update_label() {
438 let dir = tempdir().unwrap();
439 let path = dir.path().join("bookmarks.json");
440 let mut store = BookmarkStore::load(&path).unwrap();
441
442 let bm = store
443 .add(
444 BookmarkInput {
445 profile_id: pid("p1"),
446 bucket: bid("b"),
447 prefix: "".to_string(),
448 label: Some("old".to_string()),
449 },
450 &path,
451 )
452 .unwrap();
453
454 let updated = store
455 .update(
456 &bm.id,
457 BookmarkPatch {
458 label: Some("new label".to_string()),
459 },
460 &path,
461 )
462 .unwrap();
463
464 assert_eq!(updated.label.as_deref(), Some("new label"));
465 let list = store.list();
467 assert_eq!(list[0].label.as_deref(), Some("new label"));
468 }
469
470 #[test]
471 fn bookmark_update_missing_returns_not_found() {
472 let dir = tempdir().unwrap();
473 let path = dir.path().join("bookmarks.json");
474 let mut store = BookmarkStore::load(&path).unwrap();
475 let err = store
476 .update("bad-id", BookmarkPatch::default(), &path)
477 .unwrap_err();
478 assert!(matches!(err, AppError::NotFound { .. }));
479 }
480
481 #[test]
482 fn bookmark_persistence_round_trip() {
483 let dir = tempdir().unwrap();
484 let path = dir.path().join("bookmarks.json");
485 {
486 let mut store = BookmarkStore::load(&path).unwrap();
487 store
488 .add(
489 BookmarkInput {
490 profile_id: pid("p1"),
491 bucket: bid("b"),
492 prefix: "x/".to_string(),
493 label: Some("X".to_string()),
494 },
495 &path,
496 )
497 .unwrap();
498 }
499 let store2 = BookmarkStore::load(&path).unwrap();
501 let list = store2.list();
502 assert_eq!(list.len(), 1);
503 assert_eq!(list[0].prefix, "x/");
504 assert_eq!(list[0].label.as_deref(), Some("X"));
505 }
506
507 #[test]
512 fn recents_track_and_list() {
513 let dir = tempdir().unwrap();
514 let path = dir.path().join("recents.json");
515 let mut store = RecentsStore::new(path);
516
517 store.track(pid("p1"), bid("b"), "a/".to_string());
518 store.track(pid("p1"), bid("b"), "b/".to_string());
519
520 let list = store.list();
521 assert_eq!(list.len(), 2);
522 assert_eq!(list[0].prefix, "b/");
524 assert_eq!(list[1].prefix, "a/");
525 }
526
527 #[test]
528 fn recents_dedup_consecutive_identical() {
529 let dir = tempdir().unwrap();
530 let path = dir.path().join("recents.json");
531 let mut store = RecentsStore::new(path);
532
533 store.track(pid("p1"), bid("b"), "x/".to_string());
534 store.track(pid("p1"), bid("b"), "x/".to_string());
535 store.track(pid("p1"), bid("b"), "x/".to_string());
536
537 let list = store.list();
538 assert_eq!(list.len(), 1, "consecutive identical locations must dedup");
539 }
540
541 #[test]
542 fn recents_ring_buffer_eviction_at_cap() {
543 let dir = tempdir().unwrap();
544 let path = dir.path().join("recents.json");
545 let mut store = RecentsStore {
546 buffer: VecDeque::new(),
547 cap: 3,
548 path,
549 };
550
551 for i in 0..5_u32 {
552 store.track(pid("p"), bid("b"), format!("{i}/"));
553 }
554
555 let list = store.list();
556 assert_eq!(list.len(), 3, "must not exceed cap");
557 assert_eq!(list[0].prefix, "4/");
559 assert_eq!(list[1].prefix, "3/");
560 assert_eq!(list[2].prefix, "2/");
561 }
562
563 #[test]
564 fn recents_clear_empties_list() {
565 let dir = tempdir().unwrap();
566 let path = dir.path().join("recents.json");
567 let mut store = RecentsStore::new(path);
568 store.track(pid("p"), bid("b"), "x/".to_string());
569 store.clear();
570 assert!(store.list().is_empty());
571 }
572
573 #[test]
574 fn recents_persistence_round_trip() {
575 let dir = tempdir().unwrap();
576 let path = dir.path().join("recents.json");
577 {
578 let mut store = RecentsStore::new(path.clone());
579 store.track(pid("p1"), bid("b"), "persisted/".to_string());
580 store.flush().expect("flush must succeed in tmpdir");
581 }
582 let store2 = RecentsStore::load(path);
583 let list = store2.list();
584 assert_eq!(list.len(), 1);
585 assert_eq!(list[0].prefix, "persisted/");
586 }
587}