Skip to main content

thread_engine/
bookmarks.rs

1//! Bookmarks — a traveler's personal constellation.
2//!
3//! The web felt navigable because you could *keep* places: bookmarks, a home
4//! page, history. This is the Thread's version of the first of those — a small,
5//! durable list of `(label, locator)` pairs a browser persists across sessions.
6//! Storage is one human-readable JSON file (view-source culture applies to your
7//! own data too); a missing or corrupt file degrades to an empty list, never an
8//! error.
9
10use std::path::PathBuf;
11
12use serde::{Deserialize, Serialize};
13
14/// One kept place: a Locator plus the label it was saved under (usually the
15/// world's title at the time).
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct Bookmark {
18    pub label: String,
19    /// A `thread://` Locator.
20    pub locator: String,
21}
22
23/// A persisted bookmark list. Every mutation saves immediately — the file is
24/// tiny and losing a bookmark to a crash would violate the "solid" tenet.
25pub struct Bookmarks {
26    path: PathBuf,
27    entries: Vec<Bookmark>,
28}
29
30impl Bookmarks {
31    /// Load from `path` (missing or unreadable → empty list, never an error).
32    pub fn load(path: impl Into<PathBuf>) -> Self {
33        let path = path.into();
34        let entries = std::fs::read_to_string(&path)
35            .ok()
36            .and_then(|text| serde_json::from_str(&text).ok())
37            .unwrap_or_default();
38        Self { path, entries }
39    }
40
41    /// The default per-user store: `~/.config/infinite/bookmarks.json` (or the
42    /// platform equivalent via `XDG_CONFIG_HOME`), falling back to the current
43    /// directory when no home is known.
44    pub fn default_path() -> PathBuf {
45        std::env::var_os("XDG_CONFIG_HOME")
46            .map(PathBuf::from)
47            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
48            .unwrap_or_else(|| PathBuf::from("."))
49            .join("infinite")
50            .join("bookmarks.json")
51    }
52
53    pub fn entries(&self) -> &[Bookmark] {
54        &self.entries
55    }
56
57    pub fn contains(&self, locator: &str) -> bool {
58        self.entries.iter().any(|b| b.locator == locator)
59    }
60
61    /// Add a bookmark (de-duplicated by Locator; re-adding refreshes the label).
62    pub fn add(&mut self, label: impl Into<String>, locator: impl Into<String>) {
63        let (label, locator) = (label.into(), locator.into());
64        if let Some(b) = self.entries.iter_mut().find(|b| b.locator == locator) {
65            b.label = label;
66        } else {
67            self.entries.push(Bookmark { label, locator });
68        }
69        self.save();
70    }
71
72    /// Remove by Locator. Returns whether anything was removed.
73    pub fn remove(&mut self, locator: &str) -> bool {
74        let before = self.entries.len();
75        self.entries.retain(|b| b.locator != locator);
76        let removed = self.entries.len() != before;
77        if removed {
78            self.save();
79        }
80        removed
81    }
82
83    /// Bookmark it if it isn't, un-bookmark it if it is (the star button).
84    /// Returns `true` when the place is bookmarked *after* the call.
85    pub fn toggle(&mut self, label: impl Into<String>, locator: impl Into<String>) -> bool {
86        let locator = locator.into();
87        if self.contains(&locator) {
88            self.remove(&locator);
89            false
90        } else {
91            self.add(label, locator);
92            true
93        }
94    }
95
96    fn save(&self) {
97        if let Some(dir) = self.path.parent() {
98            let _ = std::fs::create_dir_all(dir);
99        }
100        match serde_json::to_string_pretty(&self.entries) {
101            Ok(json) => {
102                if let Err(e) = std::fs::write(&self.path, json) {
103                    tracing::warn!("bookmarks: cannot write {}: {e}", self.path.display());
104                }
105            }
106            Err(e) => tracing::warn!("bookmarks: serialize failed: {e}"),
107        }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    fn temp_store(tag: &str) -> PathBuf {
116        std::env::temp_dir().join(format!("loom-bookmarks-{}-{tag}.json", std::process::id()))
117    }
118
119    #[test]
120    fn add_toggle_and_persist_across_loads() {
121        let path = temp_store("roundtrip");
122        let _ = std::fs::remove_file(&path);
123
124        let mut b = Bookmarks::load(&path);
125        assert!(b.entries().is_empty());
126        b.add("The Nexus", "thread://nexus.pixygon.io/nexus");
127        assert!(b.contains("thread://nexus.pixygon.io/nexus"));
128
129        // The star button: on, then off.
130        assert!(
131            b.toggle("The Forge", "thread://pixiel.ai/forge"),
132            "first toggle bookmarks it"
133        );
134        assert!(
135            !b.toggle("The Forge", "thread://pixiel.ai/forge"),
136            "second removes it"
137        );
138
139        // A fresh load sees what was saved.
140        let again = Bookmarks::load(&path);
141        assert_eq!(again.entries().len(), 1);
142        assert_eq!(again.entries()[0].label, "The Nexus");
143
144        let _ = std::fs::remove_file(&path);
145    }
146
147    #[test]
148    fn re_adding_refreshes_the_label_without_duplicating() {
149        let path = temp_store("relabel");
150        let _ = std::fs::remove_file(&path);
151
152        let mut b = Bookmarks::load(&path);
153        b.add("Old Title", "thread://a/x");
154        b.add("New Title", "thread://a/x");
155        assert_eq!(b.entries().len(), 1);
156        assert_eq!(b.entries()[0].label, "New Title");
157
158        let _ = std::fs::remove_file(&path);
159    }
160
161    #[test]
162    fn a_corrupt_store_degrades_to_empty_not_error() {
163        let path = temp_store("corrupt");
164        std::fs::write(&path, "not json at all {{{").unwrap();
165        let b = Bookmarks::load(&path);
166        assert!(b.entries().is_empty());
167        let _ = std::fs::remove_file(&path);
168    }
169}