Skip to main content

room_cli/oneshot/
transport.rs

1use std::path::Path;
2
3use tokio::{
4    io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
5    net::UnixStream,
6};
7
8use crate::message::Message;
9
10// ── SocketTarget ──────────────────────────────────────────────────────────────
11
12/// Resolved connection target for a broker.
13///
14/// When `daemon_room` is `Some(room_id)`, the client is connecting to the
15/// multi-room daemon (`roomd`) and must prepend `ROOM:<room_id>:` before every
16/// handshake token so the daemon can route the connection to the correct room.
17///
18/// When `daemon_room` is `None`, the client is connecting to a single-room
19/// broker socket and sends handshake tokens directly (e.g. `TOKEN:<uuid>`).
20#[derive(Debug, Clone)]
21pub struct SocketTarget {
22    /// Path to the UDS socket.
23    pub path: std::path::PathBuf,
24    /// If `Some(room_id)`, prepend `ROOM:<room_id>:` before each handshake token.
25    pub daemon_room: Option<String>,
26}
27
28impl SocketTarget {
29    /// Construct the full first line to send for a given handshake token.
30    ///
31    /// - Per-room: `TOKEN:<uuid>` → `"TOKEN:<uuid>"`
32    /// - Daemon: `TOKEN:<uuid>` → `"ROOM:<room_id>:TOKEN:<uuid>"`
33    fn handshake_line(&self, token_line: &str) -> String {
34        match &self.daemon_room {
35            Some(room_id) => format!("ROOM:{room_id}:{token_line}"),
36            None => token_line.to_owned(),
37        }
38    }
39}
40
41// ── Socket target resolution ──────────────────────────────────────────────────
42
43/// Resolve the effective socket target for a given room.
44///
45/// Resolution order:
46/// 1. If `explicit` is given, use it. If the path is not the per-room socket
47///    for this room, assume it is a daemon socket and use the `ROOM:` prefix.
48/// 2. Otherwise (auto-discovery): try the platform-native daemon socket first
49///    (`room_socket_path()`); fall back to the per-room socket if the daemon
50///    socket does not exist.
51pub fn resolve_socket_target(room_id: &str, explicit: Option<&Path>) -> SocketTarget {
52    let per_room = crate::paths::room_single_socket_path(room_id);
53    // ROOM_SOCKET env var overrides the default daemon socket path.
54    let daemon = crate::paths::effective_socket_path(None);
55
56    if let Some(path) = explicit {
57        // If the caller gave us the per-room socket path, use per-room mode.
58        // Any other explicit path is treated as a daemon socket.
59        if path == per_room {
60            return SocketTarget {
61                path: path.to_owned(),
62                daemon_room: None,
63            };
64        }
65        return SocketTarget {
66            path: path.to_owned(),
67            daemon_room: Some(room_id.to_owned()),
68        };
69    }
70
71    // Auto-discovery: prefer daemon if it is running.
72    if daemon.exists() {
73        SocketTarget {
74            path: daemon,
75            daemon_room: Some(room_id.to_owned()),
76        }
77    } else {
78        SocketTarget {
79            path: per_room,
80            daemon_room: None,
81        }
82    }
83}
84
85// ── Daemon auto-start ─────────────────────────────────────────────────────────
86
87const DAEMON_POLL_INTERVAL_MS: u64 = 50;
88const DAEMON_START_TIMEOUT_MS: u64 = 5_000;
89
90/// Ensure the multi-room daemon is running.
91///
92/// If the daemon socket is not connectable, spawns `room daemon` as a detached
93/// background process, writes its PID to `~/.room/roomd.pid`, and polls until
94/// the socket accepts connections (up to 15 seconds).
95///
96/// This is a no-op when the caller passes an explicit `--socket` override — in
97/// that case the caller is targeting a specific socket and the daemon should not
98/// be auto-started on their behalf.
99///
100/// # Errors
101///
102/// Returns an error if the process cannot be spawned or if the socket does not
103/// become connectable within the timeout.
104pub async fn ensure_daemon_running() -> anyhow::Result<()> {
105    let exe = resolve_daemon_binary()?;
106    // Respect ROOM_SOCKET env var when deciding where to start/find the daemon.
107    ensure_daemon_running_impl(&crate::paths::effective_socket_path(None), &exe).await
108}
109
110/// Resolve which binary to spawn as the daemon.
111///
112/// Resolution order:
113/// 1. `ROOM_BINARY` env var (explicit override for testing).
114/// 2. `which room` — the installed binary on `$PATH`.
115/// 3. `current_exe()` — fallback to the running binary.
116///
117/// Using the installed binary (not `current_exe()`) ensures all agents
118/// converge on a single shared daemon regardless of which git worktree
119/// they run from.
120fn resolve_daemon_binary() -> anyhow::Result<std::path::PathBuf> {
121    // 1. Explicit override.
122    if let Ok(p) = std::env::var("ROOM_BINARY") {
123        let path = std::path::PathBuf::from(&p);
124        if path.exists() {
125            return Ok(path);
126        }
127    }
128
129    // 2. Installed binary on PATH.
130    if let Ok(output) = std::process::Command::new("which").arg("room").output() {
131        if output.status.success() {
132            let path_str = String::from_utf8_lossy(&output.stdout);
133            let path = std::path::PathBuf::from(path_str.trim());
134            if path.exists() {
135                return Ok(path);
136            }
137        }
138    }
139
140    // 3. Fallback to current executable.
141    std::env::current_exe().map_err(|e| anyhow::anyhow!("cannot resolve daemon binary: {e}"))
142}
143
144/// Test-visible variant: accepts explicit socket and exe paths so tests can
145/// target temp paths without relying on `current_exe()`.
146#[cfg(test)]
147pub(crate) async fn ensure_daemon_running_at(
148    socket: &Path,
149    exe: &std::path::Path,
150) -> anyhow::Result<()> {
151    ensure_daemon_running_impl(socket, exe).await
152}
153
154async fn ensure_daemon_running_impl(socket: &Path, exe: &Path) -> anyhow::Result<()> {
155    // Fast path: daemon is already running.
156    if UnixStream::connect(socket).await.is_ok() {
157        return Ok(());
158    }
159
160    let child = std::process::Command::new(exe)
161        .arg("daemon")
162        .arg("--socket")
163        .arg(socket)
164        .stdin(std::process::Stdio::null())
165        .stdout(std::process::Stdio::null())
166        .stderr(std::process::Stdio::null())
167        .spawn()
168        .map_err(|e| anyhow::anyhow!("failed to spawn daemon ({}): {e}", exe.display()))?;
169
170    // Persist PID so the user (or cleanup scripts) can identify the process.
171    let pid_path = crate::paths::room_pid_path();
172    let _ = std::fs::write(&pid_path, child.id().to_string());
173
174    // Poll until the socket accepts connections.
175    let deadline =
176        tokio::time::Instant::now() + tokio::time::Duration::from_millis(DAEMON_START_TIMEOUT_MS);
177
178    loop {
179        if UnixStream::connect(socket).await.is_ok() {
180            return Ok(());
181        }
182        if tokio::time::Instant::now() >= deadline {
183            anyhow::bail!(
184                "daemon failed to start within {}ms (socket: {})",
185                DAEMON_START_TIMEOUT_MS,
186                socket.display()
187            );
188        }
189        tokio::time::sleep(tokio::time::Duration::from_millis(DAEMON_POLL_INTERVAL_MS)).await;
190    }
191}
192
193// ── Transport functions ───────────────────────────────────────────────────────
194
195/// Connect to a running broker and deliver a single message without joining the room.
196/// Returns the broadcast echo (with broker-assigned id/ts) so callers have the message ID.
197///
198/// # Deprecation
199///
200/// Uses the `SEND:<username>` handshake which bypasses token authentication.
201/// Use [`send_message_with_token`] instead — obtain a token via `room join` first.
202#[deprecated(
203    since = "3.1.0",
204    note = "SEND: handshake is unauthenticated; use send_message_with_token instead"
205)]
206pub async fn send_message(
207    socket_path: &Path,
208    username: &str,
209    content: &str,
210) -> anyhow::Result<Message> {
211    let stream = UnixStream::connect(socket_path).await.map_err(|e| {
212        anyhow::anyhow!("cannot connect to broker at {}: {e}", socket_path.display())
213    })?;
214    let (r, mut w) = stream.into_split();
215    w.write_all(format!("SEND:{username}\n").as_bytes()).await?;
216    w.write_all(format!("{content}\n").as_bytes()).await?;
217
218    let mut reader = BufReader::new(r);
219    let mut line = String::new();
220    reader.read_line(&mut line).await?;
221    let msg: Message = serde_json::from_str(line.trim())
222        .map_err(|e| anyhow::anyhow!("broker returned invalid JSON: {e}: {:?}", line.trim()))?;
223    Ok(msg)
224}
225
226/// Connect to a running broker and deliver a single message authenticated by token.
227///
228/// When `target.daemon_room` is `Some(room_id)`, sends
229/// `ROOM:<room_id>:TOKEN:<token>` as the handshake so the daemon routes
230/// the connection to the correct room. For a per-room socket the handshake
231/// is simply `TOKEN:<token>`.
232pub async fn send_message_with_token(
233    socket_path: &Path,
234    token: &str,
235    content: &str,
236) -> anyhow::Result<Message> {
237    send_message_with_token_target(
238        &SocketTarget {
239            path: socket_path.to_owned(),
240            daemon_room: None,
241        },
242        token,
243        content,
244    )
245    .await
246}
247
248/// Variant of [`send_message_with_token`] that takes a fully-resolved
249/// [`SocketTarget`], including daemon routing prefix when required.
250pub async fn send_message_with_token_target(
251    target: &SocketTarget,
252    token: &str,
253    content: &str,
254) -> anyhow::Result<Message> {
255    let stream = UnixStream::connect(&target.path).await.map_err(|e| {
256        anyhow::anyhow!("cannot connect to broker at {}: {e}", target.path.display())
257    })?;
258    let (r, mut w) = stream.into_split();
259    let handshake = target.handshake_line(&format!("TOKEN:{token}"));
260    w.write_all(format!("{handshake}\n").as_bytes()).await?;
261    // content is already a JSON envelope from cmd_send; newlines are escaped by serde.
262    w.write_all(format!("{content}\n").as_bytes()).await?;
263
264    let mut reader = BufReader::new(r);
265    let mut line = String::new();
266    reader.read_line(&mut line).await?;
267    // Broker may return an error envelope instead of a broadcast echo.
268    let v: serde_json::Value = serde_json::from_str(line.trim())
269        .map_err(|e| anyhow::anyhow!("broker returned invalid JSON: {e}: {:?}", line.trim()))?;
270    if v["type"] == "error" {
271        let code = v["code"].as_str().unwrap_or("unknown");
272        if code == "invalid_token" {
273            anyhow::bail!("invalid token — run: room join {}", target.path.display());
274        }
275        anyhow::bail!("broker error: {code}");
276    }
277    let msg: Message = serde_json::from_value(v)
278        .map_err(|e| anyhow::anyhow!("broker returned unexpected JSON: {e}"))?;
279    Ok(msg)
280}
281
282/// Register a username with the broker and obtain a session token.
283///
284/// The broker checks for username collisions. On success it returns a token
285/// envelope; on collision it returns an error envelope.
286pub async fn join_session(socket_path: &Path, username: &str) -> anyhow::Result<(String, String)> {
287    join_session_target(
288        &SocketTarget {
289            path: socket_path.to_owned(),
290            daemon_room: None,
291        },
292        username,
293    )
294    .await
295}
296
297/// Variant of [`join_session`] that takes a fully-resolved [`SocketTarget`].
298pub async fn join_session_target(
299    target: &SocketTarget,
300    username: &str,
301) -> anyhow::Result<(String, String)> {
302    let stream = UnixStream::connect(&target.path).await.map_err(|e| {
303        anyhow::anyhow!("cannot connect to broker at {}: {e}", target.path.display())
304    })?;
305    let (r, mut w) = stream.into_split();
306    let handshake = target.handshake_line(&format!("JOIN:{username}"));
307    w.write_all(format!("{handshake}\n").as_bytes()).await?;
308
309    let mut reader = BufReader::new(r);
310    let mut line = String::new();
311    reader.read_line(&mut line).await?;
312    let v: serde_json::Value = serde_json::from_str(line.trim())
313        .map_err(|e| anyhow::anyhow!("broker returned invalid JSON: {e}: {:?}", line.trim()))?;
314    if v["type"] == "error" {
315        let code = v["code"].as_str().unwrap_or("unknown");
316        if code == "username_taken" {
317            anyhow::bail!("username '{}' is already in use in this room", username);
318        }
319        anyhow::bail!("broker error: {code}");
320    }
321    let token = v["token"]
322        .as_str()
323        .ok_or_else(|| anyhow::anyhow!("broker response missing 'token' field"))?
324        .to_owned();
325    let returned_user = v["username"]
326        .as_str()
327        .ok_or_else(|| anyhow::anyhow!("broker response missing 'username' field"))?
328        .to_owned();
329    Ok((returned_user, token))
330}
331
332/// Global user registration: sends `JOIN:<username>` directly to the daemon.
333///
334/// Unlike [`join_session`] which routes through `ROOM:<room_id>:JOIN:<username>`,
335/// this sends `JOIN:<username>` at daemon level — no room association.
336/// Returns the existing token if the username is already registered.
337pub async fn global_join_session(
338    socket_path: &Path,
339    username: &str,
340) -> anyhow::Result<(String, String)> {
341    let stream = UnixStream::connect(socket_path).await.map_err(|e| {
342        anyhow::anyhow!("cannot connect to daemon at {}: {e}", socket_path.display())
343    })?;
344    let (r, mut w) = stream.into_split();
345    w.write_all(format!("JOIN:{username}\n").as_bytes()).await?;
346
347    let mut reader = BufReader::new(r);
348    let mut line = String::new();
349    reader.read_line(&mut line).await?;
350    let v: serde_json::Value = serde_json::from_str(line.trim())
351        .map_err(|e| anyhow::anyhow!("daemon returned invalid JSON: {e}: {:?}", line.trim()))?;
352    if v["type"] == "error" {
353        let code = v["code"].as_str().unwrap_or("unknown");
354        anyhow::bail!("daemon error: {code}");
355    }
356    let token = v["token"]
357        .as_str()
358        .ok_or_else(|| anyhow::anyhow!("daemon response missing 'token' field"))?
359        .to_owned();
360    let returned_user = v["username"]
361        .as_str()
362        .ok_or_else(|| anyhow::anyhow!("daemon response missing 'username' field"))?
363        .to_owned();
364    Ok((returned_user, token))
365}
366
367// ── Room creation ────────────────────────────────────────────────────────────
368
369/// Connect to a daemon socket and create a new room via `CREATE:<room_id>`.
370///
371/// Sends the room ID on the first line and the config JSON (with `token` field)
372/// on the second. Returns the daemon's response JSON on success
373/// (`{"type":"room_created",...}`).
374///
375/// The `config_json` should include a `"token"` field for authentication.
376/// Use [`inject_token_into_config`] to add it if not already present.
377pub async fn create_room(
378    socket_path: &Path,
379    room_id: &str,
380    config_json: &str,
381) -> anyhow::Result<serde_json::Value> {
382    let stream = UnixStream::connect(socket_path).await.map_err(|e| {
383        anyhow::anyhow!("cannot connect to daemon at {}: {e}", socket_path.display())
384    })?;
385    let (r, mut w) = stream.into_split();
386    w.write_all(format!("CREATE:{room_id}\n").as_bytes())
387        .await?;
388    w.write_all(format!("{config_json}\n").as_bytes()).await?;
389
390    let mut reader = BufReader::new(r);
391    let mut line = String::new();
392    reader.read_line(&mut line).await?;
393    let v: serde_json::Value = serde_json::from_str(line.trim())
394        .map_err(|e| anyhow::anyhow!("daemon returned invalid JSON: {e}: {:?}", line.trim()))?;
395    if v["type"] == "error" {
396        let message = v["message"].as_str().unwrap_or("unknown error");
397        anyhow::bail!("{message}");
398    }
399    Ok(v)
400}
401
402// ── Room destruction ─────────────────────────────────────────────────────────
403
404/// Connect to a daemon socket and destroy a room via `DESTROY:<room_id>`.
405///
406/// Sends the room ID on the first line and the authentication token on the
407/// second. Returns the daemon's response JSON on success
408/// (`{"type":"room_destroyed",...}`).
409pub async fn destroy_room(
410    socket_path: &Path,
411    room_id: &str,
412    token: &str,
413) -> anyhow::Result<serde_json::Value> {
414    let stream = UnixStream::connect(socket_path).await.map_err(|e| {
415        anyhow::anyhow!("cannot connect to daemon at {}: {e}", socket_path.display())
416    })?;
417    let (r, mut w) = stream.into_split();
418    w.write_all(format!("DESTROY:{room_id}\n").as_bytes())
419        .await?;
420    w.write_all(format!("{token}\n").as_bytes()).await?;
421
422    let mut reader = BufReader::new(r);
423    let mut line = String::new();
424    reader.read_line(&mut line).await?;
425    let v: serde_json::Value = serde_json::from_str(line.trim())
426        .map_err(|e| anyhow::anyhow!("daemon returned invalid JSON: {e}: {:?}", line.trim()))?;
427    if v["type"] == "error" {
428        let message = v["message"]
429            .as_str()
430            .unwrap_or(v["code"].as_str().unwrap_or("unknown error"));
431        anyhow::bail!("{message}");
432    }
433    Ok(v)
434}
435
436/// Inject a `"token"` field into a JSON config string.
437///
438/// If the config is valid JSON, merges the token into the object.
439/// If the config is empty or not an object, wraps the token in a minimal config.
440pub fn inject_token_into_config(config_json: &str, token: &str) -> String {
441    if let Ok(mut v) = serde_json::from_str::<serde_json::Value>(config_json) {
442        if let Some(obj) = v.as_object_mut() {
443            obj.insert(
444                "token".to_owned(),
445                serde_json::Value::String(token.to_owned()),
446            );
447            return serde_json::to_string(&v).unwrap_or_default();
448        }
449    }
450    // Fallback: wrap in a new object.
451    serde_json::json!({"token": token}).to_string()
452}
453
454// ── Tests ─────────────────────────────────────────────────────────────────────
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use std::path::PathBuf;
460
461    fn per_room_target(room_id: &str) -> SocketTarget {
462        SocketTarget {
463            path: PathBuf::from(format!("/tmp/room-{room_id}.sock")),
464            daemon_room: None,
465        }
466    }
467
468    fn daemon_target(room_id: &str) -> SocketTarget {
469        SocketTarget {
470            path: PathBuf::from("/tmp/roomd.sock"),
471            daemon_room: Some(room_id.to_owned()),
472        }
473    }
474
475    // ── SocketTarget::handshake_line ──────────────────────────────────────────
476
477    #[test]
478    fn per_room_token_handshake_no_prefix() {
479        let t = per_room_target("myroom");
480        assert_eq!(t.handshake_line("TOKEN:abc-123"), "TOKEN:abc-123");
481    }
482
483    #[test]
484    fn daemon_token_handshake_has_room_prefix() {
485        let t = daemon_target("myroom");
486        assert_eq!(
487            t.handshake_line("TOKEN:abc-123"),
488            "ROOM:myroom:TOKEN:abc-123"
489        );
490    }
491
492    #[test]
493    fn per_room_join_handshake_no_prefix() {
494        let t = per_room_target("chat");
495        assert_eq!(t.handshake_line("JOIN:alice"), "JOIN:alice");
496    }
497
498    #[test]
499    fn daemon_join_handshake_has_room_prefix() {
500        let t = daemon_target("chat");
501        assert_eq!(t.handshake_line("JOIN:alice"), "ROOM:chat:JOIN:alice");
502    }
503
504    #[test]
505    fn daemon_handshake_with_hyphen_room_id() {
506        let t = daemon_target("agent-room-2");
507        assert_eq!(
508            t.handshake_line("TOKEN:uuid"),
509            "ROOM:agent-room-2:TOKEN:uuid"
510        );
511    }
512
513    // ── ensure_daemon_running_at ──────────────────────────────────────────────
514    //
515    // WARNING: these two tests spawn real `room` daemon processes.
516    // They are marked #[ignore] so they don't run in normal `cargo test`.
517    // Run explicitly with: `cargo test -p room-cli -- --ignored ensure_daemon`
518
519    /// Resolve the `room` binary from the test binary's location.
520    /// In cargo test layout: `target/debug/deps/../room` → `target/debug/room`.
521    fn room_bin() -> PathBuf {
522        let bin = std::env::current_exe()
523            .unwrap()
524            .parent()
525            .unwrap()
526            .parent()
527            .unwrap()
528            .join("room");
529        assert!(bin.exists(), "room binary not found at {}", bin.display());
530        bin
531    }
532
533    /// Verify that `ensure_daemon_running_at` is a no-op when a live socket already exists.
534    /// Ignored by default — spawns a real daemon process. Run with `cargo test -- --ignored`.
535    #[tokio::test]
536    #[ignore = "spawns a real daemon process; run explicitly with `cargo test -- --ignored`"]
537    async fn ensure_daemon_noop_when_socket_connectable() {
538        // Start a real daemon at a temp socket, verify ensure_daemon_running_at
539        // returns Ok without spawning a second process.
540        let dir = tempfile::TempDir::new().unwrap();
541        let socket = dir.path().join("roomd.sock");
542        let exe = room_bin();
543
544        let mut child = tokio::process::Command::new(&exe)
545            .args(["daemon", "--socket"])
546            .arg(&socket)
547            .stdin(std::process::Stdio::null())
548            .stdout(std::process::Stdio::null())
549            .stderr(std::process::Stdio::null())
550            .spawn()
551            .expect("failed to spawn room daemon");
552
553        // Wait for socket to become connectable (up to 10s under parallel load).
554        for _ in 0..200 {
555            if tokio::net::UnixStream::connect(&socket).await.is_ok() {
556                break;
557            }
558            tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
559        }
560        assert!(
561            tokio::net::UnixStream::connect(&socket).await.is_ok(),
562            "daemon socket not ready"
563        );
564
565        // Calling again must be a no-op (does not error).
566        ensure_daemon_running_at(&socket, &exe).await.unwrap();
567
568        child.kill().await.ok();
569    }
570
571    /// Verify that `ensure_daemon_running_at` auto-starts a daemon when none is running.
572    /// Ignored by default — spawns a real daemon process. Run with `cargo test -- --ignored`.
573    #[tokio::test]
574    #[ignore = "spawns a real daemon process; run explicitly with `cargo test -- --ignored`"]
575    async fn ensure_daemon_starts_daemon_and_writes_pid() {
576        let dir = tempfile::TempDir::new().unwrap();
577        let socket = dir.path().join("autostart.sock");
578        let exe = room_bin();
579
580        // No daemon running yet — ensure_daemon_running_at must start one.
581        ensure_daemon_running_at(&socket, &exe).await.unwrap();
582
583        // Socket should now be connectable.
584        assert!(
585            tokio::net::UnixStream::connect(&socket).await.is_ok(),
586            "daemon socket not connectable after auto-start"
587        );
588        // Note: PID file is written to the global room_pid_path() which other
589        // parallel tests may also write/clean up. Asserting its contents here
590        // would be racy. Verifying socket connectivity is sufficient.
591        //
592        // TempDir drop cleans up the socket; the daemon will exit when it can
593        // no longer accept connections.
594    }
595
596    // ── resolve_socket_target ─────────────────────────────────────────────────
597
598    #[test]
599    fn resolve_explicit_per_room_socket_is_not_daemon() {
600        let per_room = crate::paths::room_single_socket_path("myroom");
601        let target = resolve_socket_target("myroom", Some(&per_room));
602        assert_eq!(target.path, per_room);
603        assert!(
604            target.daemon_room.is_none(),
605            "per-room socket should not set daemon_room"
606        );
607    }
608
609    #[test]
610    fn resolve_explicit_daemon_socket_is_daemon() {
611        let daemon_sock = PathBuf::from("/tmp/roomd.sock");
612        let target = resolve_socket_target("myroom", Some(&daemon_sock));
613        assert_eq!(target.path, daemon_sock);
614        assert_eq!(target.daemon_room.as_deref(), Some("myroom"));
615    }
616
617    #[test]
618    fn resolve_explicit_custom_path_is_daemon() {
619        let custom = PathBuf::from("/var/run/roomd-test.sock");
620        let target = resolve_socket_target("chat", Some(&custom));
621        assert_eq!(target.path, custom);
622        assert_eq!(target.daemon_room.as_deref(), Some("chat"));
623    }
624
625    #[test]
626    fn resolve_auto_no_daemon_falls_back_to_per_room() {
627        // When no daemon socket exists (we check the real daemon path, which
628        // is unlikely to exist during CI), auto-discovery should fall back.
629        // We can only test this if the daemon socket is NOT running.
630        let daemon_path = crate::paths::room_socket_path();
631        if !daemon_path.exists() {
632            let target = resolve_socket_target("myroom", None);
633            assert_eq!(target.path, crate::paths::room_single_socket_path("myroom"));
634            assert!(target.daemon_room.is_none());
635        }
636        // If daemon IS running, skip (we can't test both branches in one call).
637    }
638
639    // ── resolve_daemon_binary ────────────────────────────────────────────────
640
641    /// Env var access is process-global; serialize tests that mutate it.
642    static TRANSPORT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
643
644    #[test]
645    fn resolve_daemon_binary_uses_room_binary_env() {
646        let _lock = TRANSPORT_ENV_LOCK.lock().unwrap();
647        let key = "ROOM_BINARY";
648        let prev = std::env::var(key).ok();
649
650        // Point ROOM_BINARY at a real binary that exists.
651        let target = std::env::current_exe().unwrap();
652        std::env::set_var(key, &target);
653        let result = resolve_daemon_binary().unwrap();
654        assert_eq!(result, target, "should use ROOM_BINARY when set");
655
656        match prev {
657            Some(v) => std::env::set_var(key, v),
658            None => std::env::remove_var(key),
659        }
660    }
661
662    #[test]
663    fn resolve_daemon_binary_ignores_nonexistent_room_binary() {
664        let _lock = TRANSPORT_ENV_LOCK.lock().unwrap();
665        let key = "ROOM_BINARY";
666        let prev = std::env::var(key).ok();
667
668        std::env::set_var(key, "/nonexistent/path/to/room");
669        let result = resolve_daemon_binary().unwrap();
670        // Should NOT be the nonexistent path — falls through to which/current_exe.
671        assert_ne!(
672            result,
673            std::path::PathBuf::from("/nonexistent/path/to/room"),
674            "should skip ROOM_BINARY when path does not exist"
675        );
676
677        match prev {
678            Some(v) => std::env::set_var(key, v),
679            None => std::env::remove_var(key),
680        }
681    }
682
683    #[test]
684    fn resolve_daemon_binary_falls_back_without_env() {
685        let _lock = TRANSPORT_ENV_LOCK.lock().unwrap();
686        let key = "ROOM_BINARY";
687        let prev = std::env::var(key).ok();
688
689        std::env::remove_var(key);
690        let result = resolve_daemon_binary().unwrap();
691        // Should resolve to either `which room` or current_exe — either way a real path.
692        assert!(result.exists(), "resolved binary should exist: {result:?}");
693
694        match prev {
695            Some(v) => std::env::set_var(key, v),
696            None => std::env::remove_var(key),
697        }
698    }
699}