Skip to main content

domi_server/tools/
push.rs

1// `domi push` — POST a single event to `POST /api/events`.
2//!
3//! Task 2 froze the clap surface (`--type`, `--doc`, `--target`, `--json`).
4//! Task 3 implements the actual `reqwest::Client::post` round-trip and
5//! builds the event payload (defaulting from `--type`/`--doc`/`--target`
6//! or honoring `--json` verbatim).
7//!
8//! Exit codes (consistent with `cli.rs` doc):
9//! - `0` — server returned 2xx (204 No Content on a successful POST).
10//! - `1` — network / I/O failure (connect refused, DNS, TLS, etc.).
11//! - `2` — protocol / validation failure (invalid `--json`, server
12//!   returned 4xx/5xx, malformed URL).
13//!
14//! Wire-protocol note: the Rust `Event` struct (in
15//! `crates/domi-server/src/events/event.rs`) and
16//! `docs/schemas/event.schema.json` are the source of truth for the
17//! event body. The synthesized payload uses `kind` (not `type`) and
18//! `data` (kind-specific) — see AGENTS.md "Cross-language drift" rule.
19//! When `--json` is supplied the body is forwarded verbatim, so the
20//! caller is responsible for matching the wire protocol.
21
22use std::time::Duration;
23
24use clap::Args as ClapArgs;
25use reqwest::Client;
26use serde_json::{json, Value};
27use url::Url;
28
29/// Args for `domi push` (see `crate::tools::cli::PushArgs` for docs).
30#[derive(Debug, Clone, ClapArgs)]
31pub struct PushArgs {
32    /// Event kind (e.g., `comment`, `rail-add`, `click`).
33    #[arg(long)]
34    pub r#type: String,
35
36    /// Document path (under `.domi/output/`) this event belongs to.
37    #[arg(long)]
38    pub doc: Option<String>,
39
40    /// Target element identifier (e.g., CSS selector or DOM id).
41    #[arg(long)]
42    pub target: Option<String>,
43
44    /// Full event payload as a JSON string. When provided, overrides the
45    /// `--type`/`--doc`/`--target` defaults and is sent verbatim.
46    #[arg(long)]
47    pub json: Option<String>,
48}
49
50/// POST a single event to the configured server.
51///
52/// Returns the process exit code (0 / 1 / 2). The body is either
53/// `--json` (verbatim) or synthesized from the other flags.
54pub async fn run(args: PushArgs, server: &Url) -> i32 {
55    // 1. Build the body. --json wins (verbatim); otherwise synthesize
56    //    from --type / --doc / --target, mapping `type` (user-facing)
57    //    to `kind` (wire protocol).
58    //
59    //    We OMIT `ts` so the server stamps it (see
60    //    `handlers::post_event`). `id: null` lets the server stamp a
61    //    fresh ULID. `src` defaults to `domi.js` (the agent-CLI
62    //    default; the browser runtime overrides it).
63    let body: Value = match args.json {
64        Some(raw) => match serde_json::from_str(&raw) {
65            Ok(v) => v,
66            Err(e) => {
67                eprintln!("invalid --json: {e}");
68                return 2;
69            }
70        },
71        None => json!({
72            "v": 2,
73            "id": null,
74            "src": "domi.js",
75            "kind": args.r#type,
76            "doc": args.doc.clone().unwrap_or_else(|| "synthetic".to_string()),
77            "target": {
78                "id": args.target.clone().unwrap_or_default(),
79                "selector": null,
80                "rect": {"x": 0.0, "y": 0.0, "w": 0.0, "h": 0.0},
81            },
82            "data": {},
83        }),
84    };
85
86    // 2. Build the HTTP client. 5s timeout keeps `push_unreachable`
87    //    fast-failing on connect-refused (the OS reports ECONNREFUSED
88    //    immediately on localhost, but DNS / routing delays are bounded
89    //    by the timeout so we never hang indefinitely).
90    let client = match Client::builder().timeout(Duration::from_secs(5)).build() {
91        Ok(c) => c,
92        Err(e) => {
93            eprintln!("client init: {e}");
94            return 1;
95        }
96    };
97
98    // 3. POST to /api/events relative to the configured server base.
99    let url = match server.join("/api/events") {
100        Ok(u) => u,
101        Err(e) => {
102            eprintln!("invalid server URL: {e}");
103            return 2;
104        }
105    };
106    match client.post(url).json(&body).send().await {
107        Ok(resp) if resp.status().is_success() => 0,
108        Ok(resp) => {
109            eprintln!("server returned {}", resp.status());
110            2
111        }
112        Err(e) => {
113            eprintln!("request failed: {e}");
114            1
115        }
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    //! Intentionally minimal: `push::run` is an integration-only subcommand.
122    //! The end-to-end contract is exercised by
123    //! `crates/domi-server/tests/tools_push_smoke.rs` (gated `#[ignore]`).
124    //!
125    //! A trivial test lives here so `cargo test -p domi-server` has a
126    //! passing unit test target in this file when the integration suite
127    //! is skipped (default `cargo test` excludes `--ignored`).
128    use serde_json::json;
129    #[test]
130    fn synthesized_body_omits_ts_so_server_stamps_it() {
131        // Sanity: assert the synthesized payload uses `kind` (not `type`),
132        // does NOT include `ts` (the server stamps it), and that the
133        // target is a nested object (per `event.schema.json`).
134        let body = json!({
135            "v": 2,
136            "id": null,
137            "src": "domi.js",
138            "kind": "click",
139            "doc": "synthetic",
140            "target": {
141                "id": "",
142                "selector": null,
143                "rect": {"x": 0.0, "y": 0.0, "w": 0.0, "h": 0.0},
144            },
145            "data": {},
146        });
147        assert_eq!(body["kind"], "click");
148        assert_eq!(body["v"], 2);
149        assert!(body["target"].is_object(), "target must be an object");
150        assert_eq!(body["target"]["selector"], serde_json::Value::Null);
151        assert!(
152            body.get("ts").is_none(),
153            "ts must be omitted so the server stamps it"
154        );
155        assert_eq!(body["id"], serde_json::Value::Null);
156    }
157}