Skip to main content

ant_core/node/daemon/
client.rs

1use std::path::Path;
2use std::time::Duration;
3
4use crate::error::{Error, Result};
5use crate::node::daemon::forward::{LogForwardEnableRequest, LogForwardResult, LogForwardStatus};
6use crate::node::daemon::health::FleetHealth;
7use crate::node::process::detach;
8use crate::node::types::{
9    AddNodeOpts, AddNodeResult, DaemonConfig, DaemonInfo, DaemonStartResult, DaemonStatus,
10    DaemonStopResult, NodeStarted, NodeStatusResult, NodeStopped, RemoveNodeResult, ResetResult,
11    StartNodeResult, StopNodeResult,
12};
13
14/// Get the daemon's current status by querying its REST API.
15///
16/// If the daemon is not running, returns a `DaemonStatus` with `running: false`.
17pub async fn status(config: &DaemonConfig) -> Result<DaemonStatus> {
18    let port = match read_port_file(&config.port_file_path) {
19        Some(port) => port,
20        None => {
21            return Ok(DaemonStatus {
22                running: false,
23                pid: None,
24                port: None,
25                uptime_secs: None,
26                nodes_total: 0,
27                nodes_running: 0,
28                nodes_stopped: 0,
29                nodes_errored: 0,
30            });
31        }
32    };
33
34    let url = format!("http://127.0.0.1:{port}/api/v1/status");
35    match reqwest::get(&url).await {
36        Ok(resp) => resp
37            .json::<DaemonStatus>()
38            .await
39            .map_err(|e| Error::HttpRequest(e.to_string())),
40        Err(_) => Ok(DaemonStatus {
41            running: false,
42            pid: None,
43            port: Some(port),
44            uptime_secs: None,
45            nodes_total: 0,
46            nodes_running: 0,
47            nodes_stopped: 0,
48            nodes_errored: 0,
49        }),
50    }
51}
52
53/// Stop the running daemon.
54///
55/// Reads the PID from the PID file, validates the process is actually a daemon
56/// instance, sends SIGTERM (Unix) or Ctrl+C (Windows), and waits for the process
57/// to exit.
58pub async fn stop(config: &DaemonConfig) -> Result<DaemonStopResult> {
59    let pid = read_pid_file(&config.pid_file_path)?;
60
61    // Validate the process is actually our daemon before killing it.
62    // After a crash, the PID may have been reused by an unrelated process.
63    if !is_process_alive(pid) {
64        // Process is already dead — just clean up stale files
65        let _ = std::fs::remove_file(&config.pid_file_path);
66        let _ = std::fs::remove_file(&config.port_file_path);
67        return Ok(DaemonStopResult { pid });
68    }
69
70    if !validate_daemon_process(pid) {
71        // PID is alive but isn't our daemon — clean up stale files without killing
72        let _ = std::fs::remove_file(&config.pid_file_path);
73        let _ = std::fs::remove_file(&config.port_file_path);
74        return Err(Error::DaemonStopFailed(format!(
75            "PID {pid} is alive but does not appear to be the ant daemon (possible PID reuse). \
76             Stale PID file removed."
77        )));
78    }
79
80    send_terminate(pid);
81
82    // Wait for process to exit
83    for _ in 0..50 {
84        tokio::time::sleep(Duration::from_millis(100)).await;
85        if !is_process_alive(pid) {
86            break;
87        }
88    }
89
90    // Verify the process actually died
91    if is_process_alive(pid) {
92        return Err(Error::DaemonStopFailed(format!(
93            "Daemon (PID {pid}) is still alive after 5 seconds"
94        )));
95    }
96
97    // Clean up files if they still exist
98    let _ = std::fs::remove_file(&config.pid_file_path);
99    let _ = std::fs::remove_file(&config.port_file_path);
100
101    Ok(DaemonStopResult { pid })
102}
103
104/// Start the daemon as a detached background process.
105///
106/// If the daemon is already running, returns a result with `already_running: true`.
107/// Otherwise, spawns the daemon and polls for the port file to confirm startup.
108pub async fn start(config: &DaemonConfig) -> Result<DaemonStartResult> {
109    // Check if daemon is already running
110    if let Some(pid) = check_running(&config.pid_file_path) {
111        let port = read_port_file(&config.port_file_path);
112        return Ok(DaemonStartResult {
113            already_running: true,
114            pid,
115            port,
116        });
117    }
118
119    // Get the path to the current executable
120    let exe = std::env::current_exe()
121        .map_err(|e| Error::ProcessSpawn(format!("Failed to get current executable: {e}")))?;
122    let exe_str = exe
123        .to_str()
124        .ok_or_else(|| Error::ProcessSpawn("Executable path is not valid UTF-8".to_string()))?;
125
126    let args = daemon_run_args(config);
127    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
128    let pid = detach::spawn_detached(exe_str, &arg_refs)?;
129
130    // Wait briefly for the daemon to write its port file
131    let mut port = None;
132    for _ in 0..20 {
133        tokio::time::sleep(Duration::from_millis(100)).await;
134        if let Some(p) = read_port_file(&config.port_file_path) {
135            port = Some(p);
136            break;
137        }
138    }
139
140    Ok(DaemonStartResult {
141        already_running: false,
142        pid,
143        port,
144    })
145}
146
147/// Start a specific node by ID via the daemon REST API.
148pub async fn start_node(config: &DaemonConfig, node_id: u32) -> Result<NodeStarted> {
149    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
150
151    let url = format!("http://127.0.0.1:{port}/api/v1/nodes/{node_id}/start");
152    let resp = reqwest::Client::new()
153        .post(&url)
154        .send()
155        .await
156        .map_err(|e| Error::HttpRequest(e.to_string()))?;
157
158    if resp.status().is_success() {
159        resp.json::<NodeStarted>()
160            .await
161            .map_err(|e| Error::HttpRequest(e.to_string()))
162    } else {
163        let body = resp.text().await.unwrap_or_default();
164        Err(Error::HttpRequest(body))
165    }
166}
167
168/// Read the daemon's log-forwarding status.
169pub async fn log_forward_status(config: &DaemonConfig) -> Result<LogForwardStatus> {
170    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
171
172    let url = format!("http://127.0.0.1:{port}/api/v1/logs/forward");
173    let resp = reqwest::get(&url)
174        .await
175        .map_err(|e| Error::HttpRequest(e.to_string()))?;
176
177    if resp.status().is_success() {
178        resp.json::<LogForwardStatus>()
179            .await
180            .map_err(|e| Error::HttpRequest(e.to_string()))
181    } else {
182        Err(Error::HttpRequest(resp.text().await.unwrap_or_default()))
183    }
184}
185
186/// Enable log forwarding via the daemon, so it starts shipping immediately.
187pub async fn log_forward_enable(
188    config: &DaemonConfig,
189    request: &LogForwardEnableRequest,
190) -> Result<LogForwardResult> {
191    post_log_forward(config, "enable", Some(request)).await
192}
193
194/// Disable log forwarding via the daemon.
195pub async fn log_forward_disable(config: &DaemonConfig) -> Result<LogForwardResult> {
196    post_log_forward(config, "disable", None).await
197}
198
199async fn post_log_forward(
200    config: &DaemonConfig,
201    action: &str,
202    body: Option<&LogForwardEnableRequest>,
203) -> Result<LogForwardResult> {
204    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
205
206    let url = format!("http://127.0.0.1:{port}/api/v1/logs/forward/{action}");
207    let mut request = reqwest::Client::new().post(&url);
208    if let Some(body) = body {
209        request = request.json(body);
210    }
211
212    let resp = request
213        .send()
214        .await
215        .map_err(|e| Error::HttpRequest(e.to_string()))?;
216
217    if resp.status().is_success() {
218        resp.json::<LogForwardResult>()
219            .await
220            .map_err(|e| Error::HttpRequest(e.to_string()))
221    } else {
222        // The daemon returns `{"error": "..."}` for a rejected enable; surface just that text
223        // rather than the raw JSON envelope.
224        let body = resp.text().await.unwrap_or_default();
225        let message = serde_json::from_str::<serde_json::Value>(&body)
226            .ok()
227            .and_then(|value| {
228                value
229                    .get("error")
230                    .and_then(serde_json::Value::as_str)
231                    .map(str::to_string)
232            })
233            .unwrap_or(body);
234        Err(Error::HttpRequest(message))
235    }
236}
237
238/// Start all registered nodes via the daemon REST API.
239pub async fn start_all_nodes(config: &DaemonConfig) -> Result<StartNodeResult> {
240    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
241
242    let url = format!("http://127.0.0.1:{port}/api/v1/nodes/start-all");
243    let resp = reqwest::Client::new()
244        .post(&url)
245        .send()
246        .await
247        .map_err(|e| Error::HttpRequest(e.to_string()))?;
248
249    if resp.status().is_success() {
250        resp.json::<StartNodeResult>()
251            .await
252            .map_err(|e| Error::HttpRequest(e.to_string()))
253    } else {
254        let body = resp.text().await.unwrap_or_default();
255        Err(Error::HttpRequest(body))
256    }
257}
258
259/// Stop a specific node by ID via the daemon REST API.
260pub async fn stop_node(config: &DaemonConfig, node_id: u32) -> Result<NodeStopped> {
261    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
262
263    let url = format!("http://127.0.0.1:{port}/api/v1/nodes/{node_id}/stop");
264    let resp = reqwest::Client::new()
265        .post(&url)
266        .send()
267        .await
268        .map_err(|e| Error::HttpRequest(e.to_string()))?;
269
270    if resp.status().is_success() {
271        resp.json::<NodeStopped>()
272            .await
273            .map_err(|e| Error::HttpRequest(e.to_string()))
274    } else {
275        let body = resp.text().await.unwrap_or_default();
276        Err(Error::HttpRequest(body))
277    }
278}
279
280/// Add node(s) to the registry via the daemon REST API.
281pub async fn add_node(config: &DaemonConfig, opts: &AddNodeOpts) -> Result<AddNodeResult> {
282    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
283
284    let url = format!("http://127.0.0.1:{port}/api/v1/nodes");
285    let resp = reqwest::Client::new()
286        .post(&url)
287        .json(opts)
288        .send()
289        .await
290        .map_err(|e| Error::HttpRequest(e.to_string()))?;
291
292    if resp.status().is_success() {
293        resp.json::<AddNodeResult>()
294            .await
295            .map_err(|e| Error::HttpRequest(e.to_string()))
296    } else {
297        let body = resp.text().await.unwrap_or_default();
298        Err(Error::HttpRequest(body))
299    }
300}
301
302/// Reset all node state — clear the registry and remove node data/log
303/// directories — via the daemon REST API.
304pub async fn reset(config: &DaemonConfig) -> Result<ResetResult> {
305    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
306
307    let url = format!("http://127.0.0.1:{port}/api/v1/reset");
308    let resp = reqwest::Client::new()
309        .post(&url)
310        .send()
311        .await
312        .map_err(|e| Error::HttpRequest(e.to_string()))?;
313
314    if resp.status().is_success() {
315        resp.json::<ResetResult>()
316            .await
317            .map_err(|e| Error::HttpRequest(e.to_string()))
318    } else {
319        let body = resp.text().await.unwrap_or_default();
320        Err(Error::HttpRequest(body))
321    }
322}
323
324/// Dismiss a node — remove it from the registry — via the daemon REST API.
325///
326/// Intended for evicted nodes (whose data directory has already been deleted), but the daemon will
327/// remove any non-running node. Running nodes are rejected with a conflict error.
328pub async fn dismiss_node(config: &DaemonConfig, node_id: u32) -> Result<RemoveNodeResult> {
329    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
330
331    let url = format!("http://127.0.0.1:{port}/api/v1/nodes/{node_id}");
332    let resp = reqwest::Client::new()
333        .delete(&url)
334        .send()
335        .await
336        .map_err(|e| Error::HttpRequest(e.to_string()))?;
337
338    if resp.status().is_success() {
339        resp.json::<RemoveNodeResult>()
340            .await
341            .map_err(|e| Error::HttpRequest(e.to_string()))
342    } else {
343        let body = resp.text().await.unwrap_or_default();
344        Err(Error::HttpRequest(body))
345    }
346}
347
348/// Get the current fleet health snapshot via the daemon REST API.
349pub async fn fleet_health(config: &DaemonConfig) -> Result<FleetHealth> {
350    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
351
352    let url = format!("http://127.0.0.1:{port}/api/v1/health");
353    let resp = reqwest::get(&url)
354        .await
355        .map_err(|e| Error::HttpRequest(e.to_string()))?;
356
357    if resp.status().is_success() {
358        resp.json::<FleetHealth>()
359            .await
360            .map_err(|e| Error::HttpRequest(e.to_string()))
361    } else {
362        let body = resp.text().await.unwrap_or_default();
363        Err(Error::HttpRequest(body))
364    }
365}
366
367/// Get the status of all registered nodes via the daemon REST API.
368pub async fn node_status(config: &DaemonConfig) -> Result<NodeStatusResult> {
369    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
370
371    let url = format!("http://127.0.0.1:{port}/api/v1/nodes/status");
372    let resp = reqwest::get(&url)
373        .await
374        .map_err(|e| Error::HttpRequest(e.to_string()))?;
375
376    if resp.status().is_success() {
377        resp.json::<NodeStatusResult>()
378            .await
379            .map_err(|e| Error::HttpRequest(e.to_string()))
380    } else {
381        let body = resp.text().await.unwrap_or_default();
382        Err(Error::HttpRequest(body))
383    }
384}
385
386/// Resolve a node's ID from its service name via the daemon REST API.
387///
388/// Goes through the daemon — the registry's owner — rather than reading
389/// node_registry.json directly, so the lookup cannot observe a
390/// half-written registry or race a concurrent mutation by the daemon.
391pub async fn resolve_node_id_by_name(config: &DaemonConfig, service_name: &str) -> Result<u32> {
392    let status = node_status(config).await?;
393    status
394        .nodes
395        .iter()
396        .find(|n| n.name == service_name)
397        .map(|n| n.node_id)
398        .ok_or_else(|| Error::NodeNotFoundByName(service_name.to_string()))
399}
400
401/// Stop all running nodes via the daemon REST API.
402pub async fn stop_all_nodes(config: &DaemonConfig) -> Result<StopNodeResult> {
403    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
404
405    let url = format!("http://127.0.0.1:{port}/api/v1/nodes/stop-all");
406    let resp = reqwest::Client::new()
407        .post(&url)
408        .send()
409        .await
410        .map_err(|e| Error::HttpRequest(e.to_string()))?;
411
412    if resp.status().is_success() {
413        resp.json::<StopNodeResult>()
414            .await
415            .map_err(|e| Error::HttpRequest(e.to_string()))
416    } else {
417        let body = resp.text().await.unwrap_or_default();
418        Err(Error::HttpRequest(body))
419    }
420}
421
422/// Get daemon connection info for programmatic use.
423///
424/// Reads PID and port files and checks if the process is alive.
425pub fn info(config: &DaemonConfig) -> DaemonInfo {
426    let pid = std::fs::read_to_string(&config.pid_file_path)
427        .ok()
428        .and_then(|s| s.trim().parse::<u32>().ok());
429
430    let port = read_port_file(&config.port_file_path);
431
432    let running = pid.is_some_and(is_process_alive);
433
434    DaemonInfo {
435        running,
436        pid,
437        port,
438        api_base: port.map(|p| format!("http://127.0.0.1:{p}/api/v1")),
439    }
440}
441
442/// Run the daemon in the foreground (the actual daemon process entry point).
443///
444/// Starts the HTTP server, sets up signal handling, and blocks until shutdown.
445pub async fn run(config: DaemonConfig) -> Result<()> {
446    use crate::node::daemon::server;
447    use crate::node::registry::NodeRegistry;
448
449    let registry = NodeRegistry::load(&config.registry_path)?;
450    let shutdown = tokio_util::sync::CancellationToken::new();
451
452    let shutdown_clone = shutdown.clone();
453    tokio::spawn(async move {
454        tokio::signal::ctrl_c().await.ok();
455        shutdown_clone.cancel();
456    });
457
458    let _addr = server::start(config, registry, shutdown.clone()).await?;
459
460    shutdown.cancelled().await;
461    // Give the server a moment to clean up
462    tokio::time::sleep(Duration::from_millis(100)).await;
463
464    Ok(())
465}
466
467/// Validate that a PID belongs to an ant daemon process by checking its
468/// command line. This guards against PID reuse after a daemon crash.
469#[cfg(unix)]
470fn validate_daemon_process(pid: u32) -> bool {
471    let cmdline_path = format!("/proc/{pid}/cmdline");
472    match std::fs::read(&cmdline_path) {
473        Ok(raw) => {
474            // /proc/PID/cmdline uses null bytes as separators.
475            // Check that the executable basename ends with "ant" and one
476            // of the arguments is "daemon". This avoids false positives
477            // from processes like "rant" or "phantom-daemon".
478            let args: Vec<String> = raw
479                .split(|&b| b == 0)
480                .filter(|s| !s.is_empty())
481                .map(|s| String::from_utf8_lossy(s).to_string())
482                .collect();
483            let exe_matches = args
484                .first()
485                .and_then(|exe| std::path::Path::new(exe).file_name())
486                .and_then(|name| name.to_str())
487                .is_some_and(|name| name == "ant" || name == "ant.exe");
488            let has_daemon_arg = args.iter().any(|a| a == "daemon");
489            exe_matches && has_daemon_arg
490        }
491        Err(_) => {
492            // On non-Linux Unix (macOS), /proc doesn't exist. Fall back to
493            // trusting the PID file since there's no cheap way to inspect
494            // the command line without shelling out.
495            true
496        }
497    }
498}
499
500#[cfg(windows)]
501fn validate_daemon_process(pid: u32) -> bool {
502    use windows_sys::Win32::Foundation::CloseHandle;
503    use windows_sys::Win32::System::Threading::{
504        OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION,
505    };
506
507    unsafe {
508        let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
509        if handle.is_null() {
510            return false;
511        }
512        let mut buf = [0u16; 1024];
513        let mut size = buf.len() as u32;
514        let success = QueryFullProcessImageNameW(handle, 0, buf.as_mut_ptr(), &mut size);
515        CloseHandle(handle);
516
517        if success == 0 {
518            return false;
519        }
520        let path = String::from_utf16_lossy(&buf[..size as usize]);
521        // Check the executable basename, not just substring
522        std::path::Path::new(&path)
523            .file_stem()
524            .and_then(|s| s.to_str())
525            .is_some_and(|name| name == "ant")
526    }
527}
528
529/// Build the arg list passed to the detached `ant node daemon run` child.
530///
531/// Overrides are forwarded explicitly so the child binds to the same address
532/// and port the caller asked for. Unset fields fall through to the child's
533/// own defaults (loopback + OS-assigned port).
534fn daemon_run_args(config: &DaemonConfig) -> Vec<String> {
535    let defaults = DaemonConfig::default();
536    let mut args = vec!["node".to_string(), "daemon".to_string(), "run".to_string()];
537    if let Some(port) = config.port {
538        args.push("--port".to_string());
539        args.push(port.to_string());
540    }
541    if config.listen_addr != defaults.listen_addr {
542        args.push("--listen-addr".to_string());
543        args.push(config.listen_addr.to_string());
544    }
545    // Without this the path would be lost at the detach boundary: `daemon start` parses it, then
546    // spawns a fresh `daemon run` process that would otherwise log nothing.
547    if let Some(ref log_path) = config.log_path {
548        args.push("--log-path".to_string());
549        args.push(log_path.display().to_string());
550    }
551    args
552}
553
554fn read_port_file(path: &Path) -> Option<u16> {
555    std::fs::read_to_string(path)
556        .ok()
557        .and_then(|s| s.trim().parse::<u16>().ok())
558}
559
560fn read_pid_file(path: &Path) -> Result<u32> {
561    let contents = std::fs::read_to_string(path).map_err(|_| Error::DaemonNotRunning)?;
562    contents
563        .trim()
564        .parse::<u32>()
565        .map_err(|_| Error::DaemonNotRunning)
566}
567
568/// Check if a daemon is running. Returns the PID if so.
569fn check_running(pid_file: &Path) -> Option<u32> {
570    let pid = read_pid_file(pid_file).ok()?;
571    if is_process_alive(pid) {
572        Some(pid)
573    } else {
574        None
575    }
576}
577
578#[cfg(unix)]
579fn pid_to_i32(pid: u32) -> Option<i32> {
580    i32::try_from(pid).ok().filter(|&p| p > 0)
581}
582
583#[cfg(unix)]
584fn send_terminate(pid: u32) {
585    if let Some(pid) = pid_to_i32(pid) {
586        unsafe {
587            libc::kill(pid, libc::SIGTERM);
588        }
589    }
590}
591
592#[cfg(windows)]
593fn send_terminate(pid: u32) {
594    use windows_sys::Win32::Foundation::CloseHandle;
595    use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
596
597    unsafe {
598        let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
599        if !handle.is_null() {
600            TerminateProcess(handle, 1);
601            CloseHandle(handle);
602        }
603    }
604}
605
606#[cfg(unix)]
607fn is_process_alive(pid: u32) -> bool {
608    let Some(pid) = pid_to_i32(pid) else {
609        return false;
610    };
611    let ret = unsafe { libc::kill(pid, 0) };
612    if ret == 0 {
613        return true;
614    }
615    // EPERM means the process exists but we lack permission to signal it
616    std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
617}
618
619#[cfg(windows)]
620fn is_process_alive(pid: u32) -> bool {
621    use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
622    use windows_sys::Win32::System::Threading::{
623        GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
624    };
625
626    unsafe {
627        let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
628        if handle.is_null() {
629            return false;
630        }
631        let mut exit_code: u32 = 0;
632        let success = GetExitCodeProcess(handle, &mut exit_code);
633        CloseHandle(handle);
634        success != 0 && exit_code == STILL_ACTIVE as u32
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use std::net::Ipv4Addr;
642
643    #[test]
644    fn run_args_default_config_has_no_overrides() {
645        let config = DaemonConfig::default();
646        let args = daemon_run_args(&config);
647        assert_eq!(args, vec!["node", "daemon", "run"]);
648    }
649
650    #[test]
651    fn run_args_forward_explicit_port() {
652        let config = DaemonConfig {
653            port: Some(8765),
654            ..DaemonConfig::default()
655        };
656        let args = daemon_run_args(&config);
657        assert_eq!(args, vec!["node", "daemon", "run", "--port", "8765"]);
658    }
659
660    /// The flag has to cross the detach boundary: `daemon start` parses it, but the process that
661    /// does the logging is the `daemon run` it spawns.
662    #[test]
663    fn run_args_forward_the_log_path() {
664        let config = DaemonConfig {
665            log_path: Some(std::path::PathBuf::from("/var/log/ant/daemon.log")),
666            ..DaemonConfig::default()
667        };
668        let args = daemon_run_args(&config);
669        assert_eq!(
670            args,
671            vec![
672                "node",
673                "daemon",
674                "run",
675                "--log-path",
676                "/var/log/ant/daemon.log"
677            ]
678        );
679    }
680
681    #[test]
682    fn run_args_omit_the_log_path_by_default() {
683        let args = daemon_run_args(&DaemonConfig::default());
684        assert!(
685            !args.contains(&"--log-path".to_string()),
686            "daemon logging must stay opt-in"
687        );
688    }
689
690    #[test]
691    fn run_args_forward_explicit_listen_addr() {
692        let config = DaemonConfig {
693            listen_addr: std::net::IpAddr::V4(Ipv4Addr::UNSPECIFIED),
694            ..DaemonConfig::default()
695        };
696        let args = daemon_run_args(&config);
697        assert_eq!(
698            args,
699            vec!["node", "daemon", "run", "--listen-addr", "0.0.0.0"]
700        );
701    }
702
703    #[test]
704    fn run_args_forward_both_overrides() {
705        let config = DaemonConfig {
706            port: Some(8765),
707            listen_addr: std::net::IpAddr::V4(Ipv4Addr::UNSPECIFIED),
708            ..DaemonConfig::default()
709        };
710        let args = daemon_run_args(&config);
711        assert_eq!(
712            args,
713            vec![
714                "node",
715                "daemon",
716                "run",
717                "--port",
718                "8765",
719                "--listen-addr",
720                "0.0.0.0",
721            ]
722        );
723    }
724
725    #[test]
726    fn run_args_forward_explicit_zero_port() {
727        // Explicit `--port 0` is preserved so the user's intent (OS-assigned)
728        // round-trips through the spawn, even though the child's default would
729        // produce the same bind behavior.
730        let config = DaemonConfig {
731            port: Some(0),
732            ..DaemonConfig::default()
733        };
734        let args = daemon_run_args(&config);
735        assert_eq!(args, vec!["node", "daemon", "run", "--port", "0"]);
736    }
737}