agentmux_srv/backend/
bookmarks_store.rs1use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18
19#[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 #[serde(default)]
38 #[ts(type = "number")]
39 pub created_at: i64,
40}
41
42const BOOKMARKS_FILE_NAME: &str = "browser-bookmarks.json";
43
44pub fn bookmarks_file_path() -> Option<PathBuf> {
50 agentmux_common::DataPaths::from_env().map(|p| p.shared_dir.join(BOOKMARKS_FILE_NAME))
51}
52
53pub 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
71pub 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}