agentmux_srv/server/app_api/bookmarks.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `bookmarks.list` / `bookmarks.set` — the browser pane's saved-URL list.
5//! Deliberately a thin wrapper over `backend::bookmarks_store`: no `Store`/
6//! sqlite involvement, no per-agent scoping — see
7//! `docs/specs/SPEC_BROWSER_PANE_BOOKMARKS_AND_GO_ICON_2026_08_22.md` for why
8//! this is a dedicated `shared_dir` file instead of the ABF/`db_bundles`
9//! pattern the rest of this directory mostly follows.
10
11use super::*;
12use crate::backend::bookmarks_store::{self, BrowserBookmark};
13
14pub fn register(engine: &Arc<WshRpcEngine>, _state: &AppState) {
15 register_bookmarks_list(engine);
16 register_bookmarks_set(engine);
17}
18
19/// `None` from `bookmarks_file_path()` means `DataPaths` couldn't resolve
20/// (unusual/CI env, same fallback `providers_handlers.rs` already accepts
21/// for `provider_auth_dir()`) — best-effort empty list rather than an RPC
22/// error, so a missing data-dir environment doesn't break the whole nav bar.
23fn bookmarks_list_impl() -> Result<Vec<BrowserBookmark>, String> {
24 let Some(path) = bookmarks_store::bookmarks_file_path() else {
25 return Ok(Vec::new());
26 };
27 bookmarks_store::read_bookmarks(&path)
28}
29
30fn register_bookmarks_list(engine: &Arc<WshRpcEngine>) {
31 // Typed registration (SPEC_RPC_BINDINGS_CODEGEN_2026_09_07.md §3.1) — the
32 // request/response shapes are named types carrying `#[derive(ts_rs::TS)]`,
33 // so the frontend consumes generated bindings and drift fails the build.
34 // `()` is a legal Req for a command that takes no arguments.
35 engine.register_typed(COMMAND_BOOKMARKS_LIST, move |_req: CommandBookmarksListData, _ctx| async move {
36 Ok(BookmarksResult { bookmarks: bookmarks_list_impl()? })
37 });
38}
39
40fn register_bookmarks_set(engine: &Arc<WshRpcEngine>) {
41 engine.register_typed(
42 COMMAND_BOOKMARKS_SET,
43 move |req: CommandBookmarksSetData, _ctx| async move {
44 // Unlike bookmarks.list's best-effort empty fallback, a SET
45 // with nowhere to durably land must fail loudly — silently
46 // no-oping here would tell the caller a bookmark was saved
47 // when it wasn't (see the spec's "shared_dir can't be
48 // resolved" unhappy-path entry).
49 let path = bookmarks_store::bookmarks_file_path()
50 .ok_or_else(|| "bookmarks.set: could not resolve the shared data directory".to_string())?;
51 bookmarks_store::write_bookmarks(&path, &req.bookmarks)?;
52 Ok(BookmarksResult { bookmarks: req.bookmarks })
53 },
54 );
55}
56
57#[cfg(test)]
58mod tests {
59 use super::*;
60
61 #[test]
62 fn set_req_deserializes_a_bookmarks_array() {
63 let data = json!({
64 "bookmarks": [
65 {"id": "b1", "title": "T", "url": "https://example.com"}
66 ]
67 });
68 let req: CommandBookmarksSetData = serde_json::from_value(data).unwrap();
69 assert_eq!(
70 req.bookmarks,
71 vec![BrowserBookmark {
72 id: "b1".to_string(),
73 title: "T".to_string(),
74 url: "https://example.com".to_string(),
75 favicon_url: String::new(),
76 created_at: 0,
77 }]
78 );
79 }
80
81 /// Pins the payload shape the FRONTEND actually sends for bookmarks.list.
82 /// The stub's `data` defaults to `{}`, and serde accepts `()` only from
83 /// `null` — so typing the handler's Req as `()` broke every list call at
84 /// runtime while compiling cleanly (codex P1 on PR #3293). This test fails
85 /// if anyone narrows the Req back to a unit type.
86 #[test]
87 fn list_req_accepts_the_empty_object_the_stub_sends() {
88 let sent_by_stub = json!({});
89 serde_json::from_value::<CommandBookmarksListData>(sent_by_stub)
90 .expect("bookmarks.list must accept the {} payload the frontend sends");
91 // and a unit Req would NOT have:
92 assert!(serde_json::from_value::<()>(json!({})).is_err());
93 }
94
95 #[test]
96 fn set_req_rejects_missing_bookmarks_field() {
97 let data = json!({});
98 let result: Result<CommandBookmarksSetData, _> = serde_json::from_value(data);
99 assert!(result.is_err());
100 }
101}