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}