Skip to main content

agentmux_srv/backend/
bookmarks_store.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Browser-pane bookmarks — a flat list of saved URLs, deliberately stored
5//! under `shared_dir` (`~/.agentmux/shared/browser-bookmarks.json`) rather
6//! than `settings.json`. `settings.json` isolation is whole-file only (see
7//! `docs/specs/SPEC_SETTINGS_ISOLATED_BY_CHANNEL_2026_08_19.md` §6 — per-key
8//! tiering was considered and rejected there), so a key inside it can't stay
9//! global while the rest of the file is channel-isolated. `shared_dir` is
10//! this codebase's existing mechanism for data that must always be global
11//! regardless of channel/isolation flags — the same directory already backs
12//! the agent registry, transcripts, and `provider_auth_dir()`. See
13//! `docs/specs/SPEC_BROWSER_PANE_BOOKMARKS_AND_GO_ICON_2026_08_22.md`.
14
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18
19/// One saved bookmark. `favicon_url` and `created_at` are best-effort —
20/// absent on any record written before a field was added, so both default
21/// on deserialize rather than failing the whole list over one old entry.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
23#[ts(export, export_to = "../../../frontend/types/rpc/")]
24pub struct BrowserBookmark {
25    pub id: String,
26    pub title: String,
27    pub url: String,
28    #[serde(default)]
29    pub favicon_url: String,
30    // #[ts(type = "number")]: ts-rs maps i64 -> `bigint` by default, which is
31    // not assignable to `number` in TypeScript. Every consumer of this field
32    // treats it as a JS millisecond timestamp (`ToggleBookmarkInput.now: () =>
33    // number`), and ms-since-epoch stays exactly representable in an f64 until
34    // the year 275760 — so `number` is both correct and required for the
35    // frontend to compile. Any future rpc_types field with an i64 timestamp
36    // needs this same annotation; see SPEC_RPC_BINDINGS_CODEGEN_2026_09_07.md.
37    #[serde(default)]
38    #[ts(type = "number")]
39    pub created_at: i64,
40}
41
42const BOOKMARKS_FILE_NAME: &str = "browser-bookmarks.json";
43
44/// Resolves the shared, cross-channel bookmarks file path. Returns `None`
45/// when `DataPaths` can't resolve (CI / unusual env) — mirrors
46/// `providers_handlers.rs`'s own `DataPaths::from_env()` best-effort
47/// fallback, so callers stay best-effort too rather than erroring the whole
48/// nav bar over an environment that never has this info anyway.
49pub fn bookmarks_file_path() -> Option<PathBuf> {
50    agentmux_common::DataPaths::from_env().map(|p| p.shared_dir.join(BOOKMARKS_FILE_NAME))
51}
52
53/// Read the bookmarks list. A missing file is "no bookmarks yet", not an
54/// error — every fresh install/environment starts here. A present-but-
55/// unparseable file IS an error: silently resetting to an empty list would
56/// look exactly like the user's saved bookmarks vanished, so this fails
57/// loud instead (matches this codebase's general preference for warning
58/// over silent data loss, e.g. the ABF import path's warning-budget
59/// conventions).
60pub fn read_bookmarks(path: &Path) -> Result<Vec<BrowserBookmark>, String> {
61    if !path.exists() {
62        return Ok(Vec::new());
63    }
64    let content = std::fs::read_to_string(path).map_err(|e| format!("read bookmarks: {e}"))?;
65    if content.trim().is_empty() {
66        return Ok(Vec::new());
67    }
68    serde_json::from_str(&content).map_err(|e| format!("parse bookmarks: {e}"))
69}
70
71/// Write the whole bookmarks list, replacing whatever was there before —
72/// the same wholesale-replace shape as `widget:pinned`'s `SetConfigCommand`
73/// convention, just pointed at this dedicated file instead of
74/// `settings.json`. Writes to a per-call unique temp file then renames over
75/// the real path, so a crash or concurrent write never leaves a
76/// half-written, corrupt `browser-bookmarks.json` behind (mirrors the
77/// tmp-then-rename pattern used for native-memory file writes in
78/// `bundle_import_for_agent_impl`). Two windows writing at the same instant
79/// still race at the whole-file level — last write wins, no merge — an
80/// accepted limitation, not something this function tries to solve.
81pub fn write_bookmarks(path: &Path, bookmarks: &[BrowserBookmark]) -> Result<(), String> {
82    if let Some(parent) = path.parent() {
83        std::fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
84    }
85    let json = serde_json::to_string_pretty(bookmarks).map_err(|e| format!("serialize bookmarks: {e}"))?;
86    let tmp = path.with_file_name(format!(
87        ".{BOOKMARKS_FILE_NAME}.{}.tmp",
88        uuid::Uuid::new_v4()
89    ));
90    std::fs::write(&tmp, &json).map_err(|e| format!("write bookmarks: {e}"))?;
91    std::fs::rename(&tmp, path).map_err(|e| {
92        let _ = std::fs::remove_file(&tmp);
93        format!("rename bookmarks: {e}")
94    })
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    fn bookmark(id: &str, url: &str) -> BrowserBookmark {
102        BrowserBookmark {
103            id: id.to_string(),
104            title: format!("Title for {id}"),
105            url: url.to_string(),
106            favicon_url: String::new(),
107            created_at: 0,
108        }
109    }
110
111    #[test]
112    fn missing_file_reads_as_empty_list() {
113        let dir = tempfile::tempdir().unwrap();
114        let path = dir.path().join(BOOKMARKS_FILE_NAME);
115        assert_eq!(read_bookmarks(&path).unwrap(), Vec::new());
116    }
117
118    #[test]
119    fn empty_file_reads_as_empty_list() {
120        let dir = tempfile::tempdir().unwrap();
121        let path = dir.path().join(BOOKMARKS_FILE_NAME);
122        std::fs::write(&path, "").unwrap();
123        assert_eq!(read_bookmarks(&path).unwrap(), Vec::new());
124    }
125
126    #[test]
127    fn write_then_read_round_trips() {
128        let dir = tempfile::tempdir().unwrap();
129        let path = dir.path().join(BOOKMARKS_FILE_NAME);
130        let bookmarks = vec![
131            bookmark("b1", "https://example.com"),
132            bookmark("b2", "https://agentmux.ai"),
133        ];
134        write_bookmarks(&path, &bookmarks).unwrap();
135        assert_eq!(read_bookmarks(&path).unwrap(), bookmarks);
136    }
137
138    #[test]
139    fn write_creates_missing_parent_directories() {
140        let dir = tempfile::tempdir().unwrap();
141        let path = dir.path().join("nested").join("dir").join(BOOKMARKS_FILE_NAME);
142        write_bookmarks(&path, &[bookmark("b1", "https://example.com")]).unwrap();
143        assert!(path.exists());
144    }
145
146    #[test]
147    fn write_overwrites_a_previous_list_wholesale() {
148        let dir = tempfile::tempdir().unwrap();
149        let path = dir.path().join(BOOKMARKS_FILE_NAME);
150        write_bookmarks(&path, &[bookmark("b1", "https://example.com")]).unwrap();
151        write_bookmarks(&path, &[bookmark("b2", "https://agentmux.ai")]).unwrap();
152        assert_eq!(read_bookmarks(&path).unwrap(), vec![bookmark("b2", "https://agentmux.ai")]);
153    }
154
155    #[test]
156    fn corrupt_json_fails_loud_instead_of_silently_resetting_to_empty() {
157        let dir = tempfile::tempdir().unwrap();
158        let path = dir.path().join(BOOKMARKS_FILE_NAME);
159        std::fs::write(&path, "{not valid json").unwrap();
160        let err = read_bookmarks(&path).unwrap_err();
161        assert!(err.contains("parse bookmarks"));
162    }
163
164    #[test]
165    fn old_record_missing_favicon_and_created_at_still_parses() {
166        let dir = tempfile::tempdir().unwrap();
167        let path = dir.path().join(BOOKMARKS_FILE_NAME);
168        std::fs::write(&path, r#"[{"id":"b1","title":"T","url":"https://example.com"}]"#).unwrap();
169        let bookmarks = read_bookmarks(&path).unwrap();
170        assert_eq!(
171            bookmarks,
172            vec![BrowserBookmark {
173                id: "b1".to_string(),
174                title: "T".to_string(),
175                url: "https://example.com".to_string(),
176                favicon_url: String::new(),
177                created_at: 0,
178            }]
179        );
180    }
181}