Skip to main content

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}