Skip to main content

sqry_cli/commands/
daemon.rs

1//! `sqry daemon` subcommand handlers.
2//!
3//! Provides lifecycle management for the sqryd daemon from the `sqry` CLI:
4//! - `sqry daemon start` -- resolve `sqryd` binary, exec `sqryd start --detach`.
5//! - `sqry daemon stop`  -- connect via [`DaemonClient`], send `daemon/stop`, poll socket gone.
6//! - `sqry daemon status` -- connect via [`DaemonClient`], send `daemon/status`, render.
7//! - `sqry daemon logs`  -- tail / follow the daemon log file.
8//!
9//! Additionally exposes [`try_auto_start_daemon`] for future Task 13 wiring into
10//! query commands via the `SQRY_DAEMON_AUTO_START=1` opt-in environment variable.
11//!
12//! # Tokio runtime
13//!
14//! The `stop` and `status` subcommands need async I/O for [`DaemonClient`].
15//! A `current_thread` runtime is created inside each handler — tokio is already
16//! a transitive dependency of `sqry-cli` via `sqry-lsp`.
17//!
18//! # Platform notes
19//!
20//! [`try_connect_sync`] uses `std::os::unix::net::UnixStream` on Unix and a
21//! named-pipe existence probe on Windows so callers can check reachability
22//! without spinning up a Tokio runtime.
23
24use std::io::{BufRead, Read, Seek, SeekFrom, Write};
25use std::path::{Path, PathBuf};
26use std::time::{Duration, Instant};
27
28use anyhow::{Context, Result};
29
30use crate::args::{Cli, DaemonAction};
31
32// ---------------------------------------------------------------------------
33// Constants.
34// ---------------------------------------------------------------------------
35
36/// Environment variable that enables auto-start of the daemon before query
37/// commands. Set to `"1"` to opt in. Not wired into query commands until
38/// Task 13 — this module exposes the plumbing only.
39// Not yet used by run() — Task 13 wires this into query command dispatch.
40#[allow(dead_code)]
41const ENV_DAEMON_AUTO_START: &str = "SQRY_DAEMON_AUTO_START";
42
43/// How long to poll for the socket to disappear after sending `daemon/stop`.
44const STOP_POLL_INTERVAL_MS: u64 = 100;
45
46/// How long each `tail_follow` iteration waits for a notify event before
47/// doing a fallback read.
48const FOLLOW_EVENT_TIMEOUT_MS: u64 = 250;
49
50// ---------------------------------------------------------------------------
51// Entry-point dispatcher.
52// ---------------------------------------------------------------------------
53
54/// Dispatch a `sqry daemon` subcommand.
55///
56/// # Errors
57///
58/// Propagates errors from individual subcommand handlers.
59pub fn run(_cli: &Cli, action: &DaemonAction) -> Result<()> {
60    match action {
61        DaemonAction::Start {
62            sqryd_path,
63            timeout,
64        } => run_daemon_start(sqryd_path.as_deref(), *timeout),
65        DaemonAction::Stop { timeout } => run_daemon_stop(*timeout),
66        DaemonAction::Status { json } => run_daemon_status(*json),
67        DaemonAction::Logs { lines, follow } => run_daemon_logs(*lines, *follow),
68        DaemonAction::Load { path } => run_daemon_load(path),
69        DaemonAction::Rebuild {
70            path,
71            force,
72            timeout,
73            json,
74        } => run_daemon_rebuild(path, *force, *timeout, *json),
75        DaemonAction::Reset { path, force } => run_daemon_reset(path, *force),
76    }
77}
78
79// ---------------------------------------------------------------------------
80// reset.
81// ---------------------------------------------------------------------------
82
83/// Send a `daemon/reset` JSON-RPC request for the workspace at `path`.
84/// Cluster-G §3.2 — non-destructive recovery primitive that drops the
85/// in-memory graph + admission bytes but preserves the manager-map
86/// entry. The next `daemon/load` reuses the existing on-disk
87/// snapshot.
88fn run_daemon_reset(path: &Path, force: bool) -> Result<()> {
89    let config = load_daemon_config()?;
90    let socket_path = config.socket_path();
91
92    let canonical = std::fs::canonicalize(path)
93        .with_context(|| format!("failed to canonicalize {}", path.display()))?;
94
95    if !try_connect_sync(&socket_path)? {
96        anyhow::bail!(
97            "daemon is not running (socket {}). Start it with `sqry daemon start`.",
98            socket_path.display()
99        );
100    }
101
102    let rt = tokio::runtime::Builder::new_current_thread()
103        .enable_all()
104        .build()
105        .context("failed to build tokio runtime for daemon reset")?;
106
107    rt.block_on(async {
108        let mut client = sqry_daemon_client::DaemonClient::connect(&socket_path)
109            .await
110            .with_context(|| format!("failed to connect to daemon at {}", socket_path.display()))?;
111        let result = client
112            .reset(&canonical, force)
113            .await
114            .with_context(|| format!("daemon/reset for {}", canonical.display()))?;
115        let was_reset = result
116            .get("result")
117            .and_then(|r| r.get("reset"))
118            .and_then(serde_json::Value::as_bool)
119            .or_else(|| result.get("reset").and_then(serde_json::Value::as_bool))
120            .unwrap_or(false);
121        if was_reset {
122            eprintln!("sqry: workspace {} reset", canonical.display());
123        } else {
124            eprintln!(
125                "sqry: workspace {} was not loaded; nothing to reset",
126                canonical.display()
127            );
128        }
129        Ok::<(), anyhow::Error>(())
130    })?;
131
132    Ok(())
133}
134
135// ---------------------------------------------------------------------------
136// rebuild.
137// ---------------------------------------------------------------------------
138
139fn run_daemon_rebuild(path: &Path, force: bool, timeout: u64, json: bool) -> Result<()> {
140    let config = load_daemon_config()?;
141    let socket_path = config.socket_path();
142
143    let canonical_path = std::fs::canonicalize(path)
144        .with_context(|| format!("failed to canonicalize path {}", path.display()))?;
145
146    if !try_connect_sync(&socket_path)? {
147        anyhow::bail!(
148            "daemon is not running (socket {}). Start it with `sqry daemon start`.",
149            socket_path.display()
150        );
151    }
152
153    let rt = tokio::runtime::Builder::new_current_thread()
154        .enable_all()
155        .build()
156        .context("failed to build tokio runtime for daemon rebuild")?;
157
158    rt.block_on(async {
159        let mut client = sqry_daemon_client::DaemonClient::connect(&socket_path)
160            .await
161            .with_context(|| {
162                format!("failed to connect to daemon at {}", socket_path.display())
163            })?;
164
165        let started = Instant::now();
166        let deadline = started + Duration::from_secs(timeout);
167
168        // Spawn a status-polling task that prints progress to stderr.
169        let poll_socket = socket_path.clone();
170        let poll_path = canonical_path.clone();
171        let poll_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
172        let poll_flag = std::sync::Arc::clone(&poll_done);
173        let poll_handle = tokio::spawn(async move {
174            loop {
175                tokio::time::sleep(Duration::from_secs(5)).await;
176                if poll_flag.load(std::sync::atomic::Ordering::Relaxed) {
177                    break;
178                }
179                // Connect a fresh client for status polling.
180                let Ok(mut poll_client) =
181                    sqry_daemon_client::DaemonClient::connect(&poll_socket).await
182                else {
183                    continue;
184                };
185                if let Ok(status) = poll_client.status().await {
186                    let elapsed = started.elapsed().as_secs();
187                    if let Some(ws_state) = extract_workspace_state(&status, &poll_path) {
188                        eprint!("\rsqry: {ws_state} ({elapsed}s elapsed)");
189                        let _ = std::io::stderr().flush();
190                    }
191                }
192            }
193        });
194
195        // Send the rebuild request. This blocks until the daemon finishes.
196        let result = tokio::select! {
197            res = client.rebuild(&canonical_path, force) => res,
198            () = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => {
199                poll_done.store(true, std::sync::atomic::Ordering::Relaxed);
200                let _ = poll_handle.await;
201                let elapsed_ms = started.elapsed().as_millis() as u64;
202                if json {
203                    let out = serde_json::json!({
204                        "status": "timeout",
205                        "elapsed_ms": elapsed_ms,
206                        "message": "rebuild still in progress on daemon"
207                    });
208                    println!("{}", serde_json::to_string_pretty(&out)?);
209                } else {
210                    eprintln!("\nsqry: rebuild timed out after {timeout}s (daemon continues in background)");
211                }
212                std::process::exit(2);
213            }
214        };
215
216        poll_done.store(true, std::sync::atomic::Ordering::Relaxed);
217        let _ = poll_handle.await;
218        // Clear the progress line.
219        eprint!("\r\x1b[K");
220
221        match result {
222            Ok(value) => {
223                if json {
224                    let mut out = serde_json::Map::new();
225                    out.insert(
226                        "status".to_owned(),
227                        serde_json::Value::String("completed".to_owned()),
228                    );
229                    // Extract fields from the response envelope.
230                    if let Some(r) = value.get("result") {
231                        if let Some(d) = r.get("duration_ms") {
232                            out.insert("duration_ms".to_owned(), d.clone());
233                        }
234                        if let Some(n) = r.get("nodes") {
235                            out.insert("nodes".to_owned(), n.clone());
236                        }
237                        if let Some(e) = r.get("edges") {
238                            out.insert("edges".to_owned(), e.clone());
239                        }
240                        if let Some(f) = r.get("files_indexed") {
241                            out.insert("files_indexed".to_owned(), f.clone());
242                        }
243                    }
244                    println!(
245                        "{}",
246                        serde_json::to_string_pretty(&serde_json::Value::Object(out))?
247                    );
248                } else {
249                    render_rebuild_human(&value, &canonical_path);
250                }
251            }
252            Err(sqry_daemon_client::ClientError::RpcError {
253                code: -32004,
254                message,
255                ..
256            }) => {
257                anyhow::bail!(
258                    "workspace {} is not loaded on the daemon. \
259                     Load it first with `sqry daemon load {}`.\n  (daemon said: {message})",
260                    canonical_path.display(),
261                    canonical_path.display()
262                );
263            }
264            Err(e) => {
265                return Err(anyhow::anyhow!("daemon/rebuild failed: {e}"));
266            }
267        }
268        anyhow::Ok(())
269    })?;
270
271    Ok(())
272}
273
274fn extract_workspace_state(status: &serde_json::Value, path: &Path) -> Option<String> {
275    let workspaces = status.get("result")?.get("workspaces")?.as_array()?;
276    let path_str = path.to_string_lossy();
277    for ws in workspaces {
278        if let Some(root) = ws.get("index_root").and_then(|r| r.as_str())
279            && root == path_str.as_ref()
280        {
281            return ws
282                .get("state")
283                .and_then(|s| s.as_str())
284                .map(|s| s.to_owned());
285        }
286    }
287    None
288}
289
290fn render_rebuild_human(value: &serde_json::Value, path: &Path) {
291    if let Some(r) = value.get("result") {
292        let duration = r.get("duration_ms").and_then(|d| d.as_u64()).unwrap_or(0);
293        let nodes = r.get("nodes").and_then(|n| n.as_u64()).unwrap_or(0);
294        let edges = r.get("edges").and_then(|e| e.as_u64()).unwrap_or(0);
295        let files = r.get("files_indexed").and_then(|f| f.as_u64()).unwrap_or(0);
296        let was_full = r.get("was_full").and_then(|w| w.as_bool()).unwrap_or(false);
297        let mode = if was_full { "full" } else { "incremental" };
298        eprintln!(
299            "sqry: {mode} rebuild of {} completed in {:.1}s ({nodes} nodes, {edges} edges, {files} files)",
300            path.display(),
301            duration as f64 / 1000.0
302        );
303    } else {
304        eprintln!("sqry: rebuild completed for {}", path.display());
305    }
306}
307
308// ---------------------------------------------------------------------------
309// start.
310// ---------------------------------------------------------------------------
311
312/// Resolve the sqryd binary path, check whether the daemon is already running,
313/// then exec `sqryd start --detach` and check its exit code.
314///
315/// After `sqryd start --detach` succeeds, polls the socket for up to `timeout`
316/// seconds waiting for the daemon to become reachable. Exits with an error if
317/// the daemon does not respond within the budget.
318fn run_daemon_start(sqryd_path: Option<&Path>, timeout: u64) -> Result<()> {
319    let binary = resolve_sqryd_binary(sqryd_path)?;
320
321    // Load config for socket path (best-effort; fall back to defaults on error).
322    let socket_path = load_config_socket_path();
323
324    // Idempotency: already running → exit 0.
325    if socket_path
326        .as_ref()
327        .is_some_and(|sp| try_connect_sync(sp).unwrap_or(false))
328    {
329        let sp = socket_path.as_ref().unwrap();
330        eprintln!("sqry: daemon is already running (socket {})", sp.display());
331        return Ok(());
332    }
333
334    let status = std::process::Command::new(&binary)
335        .args(["start", "--detach"])
336        .stdin(std::process::Stdio::null())
337        .stdout(std::process::Stdio::inherit())
338        .stderr(std::process::Stdio::inherit())
339        .status()
340        .with_context(|| format!("failed to exec sqryd at {}", binary.display()))?;
341
342    if !status.success() {
343        let code = status.code().unwrap_or(1);
344        // Exit code 75 = already running (sqryd EX_TEMPFAIL convention).
345        if code == 75 {
346            eprintln!("sqry: daemon is already running");
347            return Ok(());
348        }
349        anyhow::bail!("sqryd start --detach exited with code {code}");
350    }
351
352    // Poll the socket until it becomes reachable or the timeout elapses.
353    if let Some(ref sp) = socket_path {
354        poll_until_reachable(sp, timeout)?;
355        eprintln!("sqry: daemon started (socket {})", sp.display());
356    } else {
357        eprintln!("sqry: daemon started");
358    }
359    Ok(())
360}
361
362// ---------------------------------------------------------------------------
363// stop.
364// ---------------------------------------------------------------------------
365
366/// Connect to the daemon, send `daemon/stop`, then poll until the socket
367/// is unreachable or the timeout elapses.
368fn run_daemon_stop(timeout: u64) -> Result<()> {
369    let config = load_daemon_config()?;
370    let socket_path = config.socket_path();
371
372    if !try_connect_sync(&socket_path)? {
373        eprintln!("sqry: daemon is not running");
374        return Ok(());
375    }
376
377    let rt = tokio::runtime::Builder::new_current_thread()
378        .enable_all()
379        .build()
380        .context("failed to build tokio runtime for daemon stop")?;
381
382    rt.block_on(async {
383        let mut client = sqry_daemon_client::DaemonClient::connect(&socket_path)
384            .await
385            .with_context(|| format!("failed to connect to daemon at {}", socket_path.display()))?;
386
387        // Ignore stop errors — the daemon may close the connection before
388        // responding when it receives the shutdown request.
389        let _ = client.stop().await;
390
391        let deadline = Instant::now() + Duration::from_secs(timeout);
392        loop {
393            // Sleep first so we let the daemon begin shutdown.
394            tokio::time::sleep(Duration::from_millis(STOP_POLL_INTERVAL_MS)).await;
395
396            if !try_connect_async(&socket_path).await {
397                break;
398            }
399            if Instant::now() >= deadline {
400                anyhow::bail!(
401                    "daemon did not exit within {timeout} seconds; \
402                     check the daemon log for errors"
403                );
404            }
405        }
406        anyhow::Ok(())
407    })?;
408
409    eprintln!("sqry: daemon stopped");
410    Ok(())
411}
412
413// ---------------------------------------------------------------------------
414// status.
415// ---------------------------------------------------------------------------
416
417/// Connect to the daemon, send `daemon/status`, and render the response.
418fn run_daemon_status(json: bool) -> Result<()> {
419    let config = load_daemon_config()?;
420    let socket_path = config.socket_path();
421
422    // Check connectivity first so we can give a clean exit-1 without async machinery.
423    if !try_connect_sync(&socket_path)? {
424        if json {
425            // Use serde_json to produce a properly-escaped JSON error envelope —
426            // raw string interpolation can produce invalid JSON for socket paths
427            // that contain quotes or backslashes (e.g. Windows paths).
428            println!(
429                "{}",
430                serde_json::json!({
431                    "error": "daemon_unreachable",
432                    "socket": socket_path.display().to_string(),
433                })
434            );
435        } else {
436            eprintln!(
437                "sqry: daemon is not running (socket {})",
438                socket_path.display()
439            );
440        }
441        // Exit code 1 for "not running".
442        std::process::exit(1);
443    }
444
445    let rt = tokio::runtime::Builder::new_current_thread()
446        .enable_all()
447        .build()
448        .context("failed to build tokio runtime for daemon status")?;
449
450    rt.block_on(async {
451        let mut client = sqry_daemon_client::DaemonClient::connect(&socket_path)
452            .await
453            .with_context(|| format!("failed to connect to daemon at {}", socket_path.display()))?;
454
455        let result = client
456            .status()
457            .await
458            .context("daemon/status request failed")?;
459
460        if json {
461            let out = serde_json::to_string_pretty(&result)
462                .context("failed to serialize daemon status as JSON")?;
463            println!("{out}");
464        } else {
465            render_status_human(&result);
466        }
467        anyhow::Ok(())
468    })?;
469
470    Ok(())
471}
472
473// ---------------------------------------------------------------------------
474// logs.
475// ---------------------------------------------------------------------------
476
477/// Discover the daemon log file path and tail it (or follow it).
478///
479/// Cluster-G §5.3 changed the default: the daemon now logs to
480/// `<runtime_dir>/sqryd.log` automatically. The opt-out path
481/// (`log_file = "stderr"`) and the "configured-but-missing-yet" path
482/// (daemon just started, file not flushed) both fall back to the
483/// platform-specific journal hint via [`print_log_fallback_hint`]
484/// (cluster-G §5.4).
485fn run_daemon_logs(lines: usize, follow: bool) -> Result<()> {
486    let config = load_daemon_config()?;
487    let log_path = match resolve_log_path(&config) {
488        Ok(p) if p.exists() => p,
489        Ok(p) => {
490            // Configured but the file does not exist yet (daemon may
491            // have just started, or transient FS issue).
492            eprintln!(
493                "sqry: configured log file {} does not exist yet. \
494                 The daemon may have just started, or is logging to stderr.",
495                p.display()
496            );
497            return print_log_fallback_hint(&config);
498        }
499        Err(_) => return print_log_fallback_hint(&config),
500    };
501
502    if follow {
503        tail_follow(&log_path, lines)?;
504    } else {
505        tail_last_n(&log_path, lines)?;
506    }
507    Ok(())
508}
509
510/// Print the platform-specific fallback hint when no log file is
511/// available — either because the operator opted out
512/// (`log_file = "stderr"`), or because the file does not exist yet
513/// (cluster-G §5.4).
514///
515/// Returns `Ok(())` rather than an error so the CLI exits with status
516/// 0; the user already saw the diagnostic on stderr above.
517fn print_log_fallback_hint(config: &sqry_daemon::config::DaemonConfig) -> Result<()> {
518    eprintln!();
519    eprintln!(
520        "Default log location: {}",
521        config.runtime_dir().join("sqryd.log").display()
522    );
523    eprintln!("To configure a custom path, set in $XDG_CONFIG_HOME/sqry/daemon.toml:");
524    eprintln!(
525        "    log_file = \"{}\"",
526        config.runtime_dir().join("sqryd.log").display()
527    );
528    eprintln!("Or via env: SQRY_DAEMON_LOG_FILE=<path>");
529    eprintln!();
530    if cfg!(target_os = "linux") && std::env::var_os("XDG_RUNTIME_DIR").is_some() {
531        eprintln!("If running under systemd --user:");
532        eprintln!("    journalctl --user -u sqryd.service -f");
533    } else if cfg!(target_os = "linux") {
534        eprintln!("If running under systemd:");
535        eprintln!("    journalctl -u sqryd.service -f");
536    } else if cfg!(target_os = "macos") {
537        eprintln!("If running under launchd:");
538        eprintln!("    log stream --predicate 'process == \"sqryd\"'");
539    } else if cfg!(target_os = "windows") {
540        eprintln!("On Windows, use Event Viewer or `Get-WinEvent`.");
541    }
542    Ok(())
543}
544
545// ---------------------------------------------------------------------------
546// load.
547// ---------------------------------------------------------------------------
548
549/// Connect to the daemon and send `daemon/load` for the given workspace path.
550///
551/// The daemon's `WorkspaceManager` will index the workspace (if not already
552/// loaded), cache the graph in memory, and start watching for file changes.
553/// The response is printed to stderr as a human-readable confirmation.
554fn run_daemon_load(path: &Path) -> Result<()> {
555    let config = load_daemon_config()?;
556    let socket_path = config.socket_path();
557
558    // Canonicalize the workspace path eagerly so the user sees the
559    // resolved path in the output, and so the daemon's path-policy
560    // canonicalization is defence-in-depth rather than the primary check.
561    let canonical_path = std::fs::canonicalize(path)
562        .with_context(|| format!("failed to canonicalize path {}", path.display()))?;
563
564    if !try_connect_sync(&socket_path)? {
565        anyhow::bail!(
566            "daemon is not running (socket {}). Start it with `sqry daemon start`.",
567            socket_path.display()
568        );
569    }
570
571    let rt = tokio::runtime::Builder::new_current_thread()
572        .enable_all()
573        .build()
574        .context("failed to build tokio runtime for daemon load")?;
575
576    rt.block_on(async {
577        let mut client = sqry_daemon_client::DaemonClient::connect(&socket_path)
578            .await
579            .with_context(|| format!("failed to connect to daemon at {}", socket_path.display()))?;
580
581        // `DaemonClient::load` returns a strongly typed
582        // `ResponseEnvelope<LoadResult>` — schema drift between the
583        // client and daemon surfaces as `ClientError::SchemaMismatch`
584        // here, not as a silently "successful" render with default
585        // fields.
586        let envelope = client.load(&canonical_path).await.with_context(|| {
587            format!(
588                "daemon/load request failed for {}",
589                canonical_path.display()
590            )
591        })?;
592
593        let load_result = envelope.result;
594        eprintln!(
595            "sqry: workspace loaded at {} ({:?}, {})",
596            canonical_path.display(),
597            load_result.state,
598            human_bytes(load_result.current_bytes)
599        );
600        anyhow::Ok(())
601    })?;
602
603    Ok(())
604}
605
606// ---------------------------------------------------------------------------
607// Binary resolution.
608// ---------------------------------------------------------------------------
609
610/// Resolve the `sqryd` binary path.
611///
612/// Resolution order:
613/// 1. Explicit `--sqryd-path` override.
614/// 2. Sibling of `current_exe()` (canonical path, symlink-safe).
615/// 3. `SQRYD_PATH` environment variable.
616/// 4. `PATH` lookup via [`which::which`].
617///
618/// # Errors
619///
620/// Returns an error if no `sqryd` binary can be found on the current system.
621fn resolve_sqryd_binary(explicit: Option<&Path>) -> Result<PathBuf> {
622    // 1. Explicit override.
623    if let Some(path) = explicit {
624        if path.exists() {
625            return Ok(path.to_path_buf());
626        }
627        anyhow::bail!("explicit --sqryd-path {} does not exist", path.display());
628    }
629
630    // 2. Sibling of the current executable (canonical, symlink-resolved).
631    if let Ok(exe) = std::env::current_exe() {
632        // Canonicalize to follow symlinks (security: prevents ../foo attacks).
633        let canonical = std::fs::canonicalize(&exe).unwrap_or(exe);
634        if let Some(dir) = canonical.parent() {
635            let sibling = dir.join("sqryd");
636            if sibling.exists() {
637                return Ok(sibling);
638            }
639            // Windows: try .exe suffix.
640            let sibling_exe = dir.join("sqryd.exe");
641            if sibling_exe.exists() {
642                return Ok(sibling_exe);
643            }
644        }
645    }
646
647    // 3. SQRYD_PATH env var.
648    if let Some(val) = std::env::var_os("SQRYD_PATH") {
649        let path = PathBuf::from(val);
650        if path.exists() {
651            return Ok(path);
652        }
653        anyhow::bail!("SQRYD_PATH={} does not exist", path.display());
654    }
655
656    // 4. PATH lookup.
657    which::which("sqryd").with_context(|| {
658        "sqryd binary not found. \
659         Install sqryd alongside sqry, set SQRYD_PATH, or use --sqryd-path."
660            .to_owned()
661    })
662}
663
664// ---------------------------------------------------------------------------
665// Auto-start helper (Task 13 wiring point).
666// ---------------------------------------------------------------------------
667
668/// Check whether the daemon auto-start opt-in is active, and if so, try to
669/// start the daemon.
670///
671/// Returns `true` if a daemon is now running (either was already running or
672/// was successfully started), `false` if auto-start is disabled (`SQRY_DAEMON_AUTO_START`
673/// is not set to `"1"`).
674///
675/// Not wired into query commands until Task 13. Exported here so Task 13 can
676/// `use sqry_cli::commands::daemon::try_auto_start_daemon` without moving code.
677///
678/// # Errors
679///
680/// Propagates errors from [`resolve_sqryd_binary`] and
681/// `std::process::Command::status`. Errors during the binary execution are
682/// treated as a failed auto-start (returns `Ok(false)` after logging a
683/// warning).
684// Not yet called from run() — Task 13 wires this into query command dispatch.
685#[allow(dead_code)]
686pub fn try_auto_start_daemon() -> Result<bool> {
687    if std::env::var_os(ENV_DAEMON_AUTO_START).as_deref() != Some(std::ffi::OsStr::new("1")) {
688        return Ok(false);
689    }
690
691    // Resolve binary; any resolution error is surfaced to the caller.
692    let binary = resolve_sqryd_binary(None)?;
693
694    // Check if already running.
695    let socket_path = load_config_socket_path();
696    if socket_path
697        .as_ref()
698        .is_some_and(|sp| try_connect_sync(sp).unwrap_or(false))
699    {
700        return Ok(true);
701    }
702
703    // Exec sqryd start --detach.
704    let status = std::process::Command::new(&binary)
705        .args(["start", "--detach"])
706        .stdin(std::process::Stdio::null())
707        .stdout(std::process::Stdio::null())
708        .stderr(std::process::Stdio::inherit())
709        .status()
710        .with_context(|| format!("auto-start: failed to exec sqryd at {}", binary.display()))?;
711
712    if !status.success() {
713        let code = status.code().unwrap_or(1);
714        if code != 75 {
715            // 75 = already running; any other nonzero means start failed.
716            eprintln!(
717                "sqry: Warning: daemon auto-start failed (sqryd exited {code}); \
718                 falling back to local mode"
719            );
720            return Ok(false);
721        }
722    }
723
724    Ok(true)
725}
726
727// ---------------------------------------------------------------------------
728// Formatting helpers.
729// ---------------------------------------------------------------------------
730
731/// Format a byte count as a human-readable string (B / KB / MB / GB / TB).
732///
733/// Uses binary (1024-based) divisors. Values below 1 KB are rendered as `"N B"`.
734#[must_use]
735pub fn human_bytes(bytes: u64) -> String {
736    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
737    const DIVISOR: u64 = 1024;
738
739    if bytes < DIVISOR {
740        return format!("{bytes} B");
741    }
742
743    let mut value = bytes as f64;
744    let mut unit_index = 0usize;
745    while value >= DIVISOR as f64 && unit_index + 1 < UNITS.len() {
746        value /= DIVISOR as f64;
747        unit_index += 1;
748    }
749    // Use integer display when the value is exact; otherwise one decimal place.
750    if (value - value.floor()).abs() < 0.05 {
751        format!("{:.0} {}", value, UNITS[unit_index])
752    } else {
753        format!("{:.1} {}", value, UNITS[unit_index])
754    }
755}
756
757/// Format an uptime in seconds as `"Xd Yh Zm"` (or shorter when leading
758/// units are zero).
759///
760/// Examples:
761/// - `0` → `"0m"`
762/// - `59` → `"0m"`
763/// - `3600` → `"1h"`
764/// - `3660` → `"1h 1m"`
765/// - `86400` → `"1d"`
766/// - `90061` → `"1d 1h 1m"`
767#[must_use]
768pub fn format_uptime(seconds: u64) -> String {
769    let days = seconds / 86_400;
770    let hours = (seconds % 86_400) / 3_600;
771    let mins = (seconds % 3_600) / 60;
772
773    match (days, hours, mins) {
774        (0, 0, _) => format!("{mins}m"),
775        (0, h, 0) => format!("{h}h"),
776        (0, h, m) => format!("{h}h {m}m"),
777        (d, 0, 0) => format!("{d}d"),
778        (d, h, 0) => format!("{d}d {h}h"),
779        (d, h, m) => format!("{d}d {h}h {m}m"),
780    }
781}
782
783// ---------------------------------------------------------------------------
784// Status rendering.
785// ---------------------------------------------------------------------------
786
787/// Render a `daemon/status` response in human-readable format to stdout.
788///
789/// Thin wrapper around [`render_status_human_into`] that writes to
790/// `std::io::stdout()`. Call [`render_status_human_into`] directly in tests
791/// to capture and assert the rendered output.
792fn render_status_human(envelope: &serde_json::Value) {
793    let stdout = std::io::stdout();
794    let mut handle = stdout.lock();
795    // Ignore write errors to stdout (broken pipe, etc.) — CLI best-effort.
796    let _ = render_status_human_into(envelope, &mut handle);
797}
798
799/// Render a `daemon/status` response in human-readable format into `out`.
800///
801/// `envelope` is the [`ResponseEnvelope<DaemonStatus>`] returned by
802/// [`sqry_daemon_client::DaemonClient::status`]. The inner `DaemonStatus` lives
803/// under the `"result"` key; the `ResponseMeta` lives under `"meta"`.
804///
805/// Wire contract (`sqry-daemon/src/workspace/status.rs`):
806/// ```json
807/// {
808///   "result": {
809///     "daemon_version": "8.0.6",
810///     "uptime_seconds": 8040,
811///     "memory": {
812///       "limit_bytes": 2147483648,
813///       "current_bytes": 471859200,
814///       "reserved_bytes": 0,
815///       "high_water_bytes": 1288490188
816///     },
817///     "workspaces": [
818///       {
819///         "index_root": "/repos/example",
820///         "state": "Loaded",
821///         "pinned": true,
822///         "current_bytes": 335544320,
823///         "high_water_bytes": 933232896,
824///         "last_good_at": null,
825///         "last_error": null,
826///         "retry_count": 0
827///       }
828///     ]
829///   },
830///   "meta": { "stale": false, "daemon_version": "8.0.6" }
831/// }
832/// ```
833///
834/// The renderer is **opportunistic**: it renders whatever fields are present,
835/// and gracefully degrades when optional fields are absent. This future-proofs
836/// the CLI against daemon version drift.
837///
838/// Expected human output (when all fields are present):
839/// ```text
840/// sqryd v8.0.6 -- uptime 2h 14m
841///
842/// Memory: 450 MB / 2048 MB  (peak: 1.2 GB)
843///
844/// Workspaces (1 loaded):
845///   ~/repos/main-project      320 MB  (peak: 890 MB)  [pinned, Loaded]
846/// ```
847fn render_status_human_into(
848    envelope: &serde_json::Value,
849    out: &mut dyn Write,
850) -> std::io::Result<()> {
851    // The `DaemonStatus` payload lives under `"result"`.
852    // Fall back to the envelope root for forward-compat with any future
853    // unwrapped shape.
854    let inner = envelope.get("result").unwrap_or(envelope);
855
856    // ---- Header line ----
857    // `daemon_version` is in the inner DaemonStatus. The meta also carries it,
858    // but we prefer the authoritative copy in the payload.
859    let version = inner
860        .get("daemon_version")
861        .or_else(|| envelope.get("meta").and_then(|m| m.get("daemon_version")))
862        .and_then(serde_json::Value::as_str)
863        .unwrap_or("unknown");
864
865    // `uptime_seconds` is the canonical field name in DaemonStatus.
866    let uptime_str = inner
867        .get("uptime_seconds")
868        .and_then(serde_json::Value::as_u64)
869        .map(format_uptime);
870
871    match uptime_str {
872        Some(uptime) => writeln!(out, "sqryd v{version} -- uptime {uptime}")?,
873        None => writeln!(out, "sqryd v{version}")?,
874    }
875
876    // ---- Memory line ----
877    // Memory fields are nested under `"memory"` in MemoryStatus.
878    let memory = inner.get("memory");
879    let mem_current = memory
880        .and_then(|m| m.get("current_bytes"))
881        .and_then(serde_json::Value::as_u64);
882    let mem_limit = memory
883        .and_then(|m| m.get("limit_bytes"))
884        .and_then(serde_json::Value::as_u64);
885    let mem_peak = memory
886        .and_then(|m| m.get("high_water_bytes"))
887        .and_then(serde_json::Value::as_u64);
888
889    if mem_current.is_some() || mem_limit.is_some() {
890        writeln!(out)?;
891        let used_str = mem_current.map_or_else(|| "?".to_owned(), human_bytes);
892        let limit_str = mem_limit.map_or_else(|| "?".to_owned(), human_bytes);
893        match mem_peak {
894            Some(peak) => writeln!(
895                out,
896                "Memory: {used_str} / {limit_str}  (peak: {})",
897                human_bytes(peak)
898            )?,
899            None => writeln!(out, "Memory: {used_str} / {limit_str}")?,
900        }
901    }
902
903    // ---- Workspaces ----
904    let workspaces = inner
905        .get("workspaces")
906        .and_then(serde_json::Value::as_array);
907
908    if let Some(wss) = workspaces {
909        writeln!(out)?;
910        writeln!(out, "Workspaces ({} loaded):", wss.len())?;
911        for ws in wss {
912            render_workspace_line_into(ws, out)?;
913        }
914    }
915
916    Ok(())
917}
918
919/// Render a single workspace line from the status response to stdout.
920///
921/// Thin wrapper around [`render_workspace_line_into`] that writes to
922/// `std::io::stdout()`. Call [`render_workspace_line_into`] directly in tests
923/// to capture and assert the rendered output.
924///
925/// Called indirectly via [`render_status_human`] → [`render_status_human_into`] →
926/// [`render_workspace_line_into`]. Kept for standalone use and API completeness.
927#[allow(dead_code)]
928fn render_workspace_line(ws: &serde_json::Value) {
929    let stdout = std::io::stdout();
930    let mut handle = stdout.lock();
931    let _ = render_workspace_line_into(ws, &mut handle);
932}
933
934/// Render a single workspace line from the status response into `out`.
935///
936/// Field names match `WorkspaceStatus` in `sqry-daemon/src/workspace/status.rs`:
937/// - `index_root` — canonical absolute path (PathBuf serialised as string).
938/// - `current_bytes` — live graph size.
939/// - `high_water_bytes` — monotonic peak.
940/// - `state` — serde form of `WorkspaceState` (e.g. `"Loaded"`, `"Rebuilding"`).
941/// - `pinned` — LRU-exempt flag.
942/// - `last_error` — most recent error string if any.
943fn render_workspace_line_into(ws: &serde_json::Value, out: &mut dyn Write) -> std::io::Result<()> {
944    // `index_root` is the canonical field. Accept `path` as a fallback for
945    // forward-compat with any future shape change.
946    let path = ws
947        .get("index_root")
948        .or_else(|| ws.get("path"))
949        .and_then(serde_json::Value::as_str)
950        .unwrap_or("<unknown>");
951
952    // Attempt to shorten the path with home directory tilde expansion.
953    let display_path = tilde_shorten(path);
954
955    // `current_bytes` / `high_water_bytes` are the canonical memory field names.
956    let ws_mem = ws
957        .get("current_bytes")
958        .and_then(serde_json::Value::as_u64)
959        .map(human_bytes);
960    let ws_peak = ws
961        .get("high_water_bytes")
962        .and_then(serde_json::Value::as_u64)
963        .map(human_bytes);
964
965    // Collect status tags.
966    let mut tags: Vec<&str> = Vec::new();
967    if ws
968        .get("pinned")
969        .and_then(serde_json::Value::as_bool)
970        .unwrap_or(false)
971    {
972        tags.push("pinned");
973    }
974    let state = ws
975        .get("state")
976        .and_then(serde_json::Value::as_str)
977        .unwrap_or("Loaded");
978    tags.push(state);
979
980    // Surface the most recent error as a stale tag if present.
981    if let Some(err_msg) = ws.get("last_error").and_then(serde_json::Value::as_str) {
982        // Format the error tag with the reason inline.
983        let tag = format!("error: {err_msg}");
984        match (ws_mem, ws_peak) {
985            (Some(mem), Some(peak)) => {
986                writeln!(
987                    out,
988                    "  {display_path:<30}  {mem:<8}  (peak: {peak:<8})  [{tags}, {tag}]",
989                    tags = tags.join(", ")
990                )?;
991            }
992            (Some(mem), None) => {
993                writeln!(
994                    out,
995                    "  {display_path:<30}  {mem:<8}  [{tags}, {tag}]",
996                    tags = tags.join(", ")
997                )?;
998            }
999            _ => {
1000                writeln!(
1001                    out,
1002                    "  {display_path}  [{tags}, {tag}]",
1003                    tags = tags.join(", ")
1004                )?;
1005            }
1006        }
1007        return Ok(());
1008    }
1009
1010    let tag_str = format!("[{}]", tags.join(", "));
1011    match (ws_mem, ws_peak) {
1012        (Some(mem), Some(peak)) => {
1013            writeln!(
1014                out,
1015                "  {display_path:<30}  {mem:<8}  (peak: {peak:<8})  {tag_str}"
1016            )?;
1017        }
1018        (Some(mem), None) => {
1019            writeln!(out, "  {display_path:<30}  {mem:<8}  {tag_str}")?;
1020        }
1021        _ => {
1022            writeln!(out, "  {display_path}  {tag_str}")?;
1023        }
1024    }
1025    Ok(())
1026}
1027
1028/// Replace the home directory prefix in a path string with `~`.
1029fn tilde_shorten(path: &str) -> String {
1030    if let Some(home) = dirs::home_dir() {
1031        let home_str = home.to_string_lossy();
1032        if let Some(stripped) = path.strip_prefix(home_str.as_ref()) {
1033            return format!("~{stripped}");
1034        }
1035    }
1036    path.to_owned()
1037}
1038
1039// ---------------------------------------------------------------------------
1040// Log tailing.
1041// ---------------------------------------------------------------------------
1042
1043/// Print the last `n` lines from `path` to stdout.
1044///
1045/// Reads the entire file into memory as a line buffer, then emits the last `n`
1046/// lines. Suitable for typical daemon log files (up to a few hundred MB).
1047/// For multi-GB log files the caller should configure log rotation to keep
1048/// files bounded.
1049///
1050/// # Errors
1051///
1052/// Returns an error if the file cannot be opened or read.
1053pub fn tail_last_n(path: &Path, n: usize) -> Result<()> {
1054    let file = std::fs::File::open(path)
1055        .with_context(|| format!("failed to open log file {}", path.display()))?;
1056    let buf_reader = std::io::BufReader::new(file);
1057
1058    // Collect all lines (suitable for typical log files up to a few hundred MB).
1059    let mut lines: Vec<String> = Vec::new();
1060    for line in buf_reader.lines() {
1061        let l = line.with_context(|| format!("error reading log file {}", path.display()))?;
1062        lines.push(l);
1063    }
1064
1065    // Emit the last n lines.
1066    let start = lines.len().saturating_sub(n);
1067    let stdout = std::io::stdout();
1068    let mut out = stdout.lock();
1069    for line in &lines[start..] {
1070        writeln!(out, "{line}").context("failed to write to stdout")?;
1071    }
1072    Ok(())
1073}
1074
1075/// Print the last `initial_lines` lines from `path`, then follow the file
1076/// for new content using `notify::RecommendedWatcher`.
1077///
1078/// Rotation-aware: when the log file is renamed or recreated (rotation
1079/// event), the follower reopens it at the original path (standard `tail -F`
1080/// behaviour). Ctrl-C exits cleanly via the SIGINT handler inherited from
1081/// the process.
1082///
1083/// # Errors
1084///
1085/// Returns an error if the file cannot be opened, the notify watcher cannot
1086/// be created, or stdout I/O fails.
1087pub fn tail_follow(path: &Path, initial_lines: usize) -> Result<()> {
1088    use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
1089    use std::sync::mpsc;
1090
1091    // Print the last `initial_lines` lines first.
1092    tail_last_n(path, initial_lines)?;
1093
1094    // Seek to end of file so we only emit new bytes.
1095    let mut file = std::fs::File::open(path)
1096        .with_context(|| format!("failed to open log file for follow: {}", path.display()))?;
1097    let mut pos = file
1098        .seek(SeekFrom::End(0))
1099        .context("failed to seek to end of log file")?;
1100
1101    // Set up a synchronous notify watcher.
1102    let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
1103    let mut watcher = RecommendedWatcher::new(tx, notify::Config::default())
1104        .context("failed to create file watcher for log follow")?;
1105
1106    // Watch the parent directory so we catch renames/recreations.
1107    let parent = path.parent().unwrap_or(Path::new("."));
1108    watcher
1109        .watch(parent, RecursiveMode::NonRecursive)
1110        .with_context(|| format!("failed to watch log directory {}", parent.display()))?;
1111
1112    let stdout = std::io::stdout();
1113    let mut out = stdout.lock();
1114
1115    // Follow loop.
1116    loop {
1117        match rx.recv_timeout(Duration::from_millis(FOLLOW_EVENT_TIMEOUT_MS)) {
1118            Ok(Ok(event)) => {
1119                let is_rotate = matches!(event.kind, EventKind::Remove(_) | EventKind::Create(_))
1120                    && event.paths.iter().any(|p| p == path);
1121
1122                if is_rotate {
1123                    // Rotation detected — reopen at original path.
1124                    if path.exists() {
1125                        match std::fs::File::open(path) {
1126                            Ok(f) => {
1127                                file = f;
1128                                pos = 0;
1129                            }
1130                            Err(e) => {
1131                                eprintln!("sqry: log rotation detected but reopen failed: {e}");
1132                            }
1133                        }
1134                    }
1135                }
1136
1137                // Read any new bytes.
1138                pos = drain_new_bytes(&mut file, pos, path, &mut out)?;
1139            }
1140            Ok(Err(e)) => {
1141                eprintln!("sqry: file watcher error: {e}");
1142            }
1143            Err(mpsc::RecvTimeoutError::Timeout) => {
1144                // Fallback poll in case notify misses events on some platforms.
1145                pos = drain_new_bytes(&mut file, pos, path, &mut out)?;
1146            }
1147            Err(mpsc::RecvTimeoutError::Disconnected) => {
1148                break;
1149            }
1150        }
1151    }
1152
1153    Ok(())
1154}
1155
1156/// Read bytes from `file` starting at `current_pos`, writing them to `out`.
1157/// Returns the new file position.
1158fn drain_new_bytes(
1159    file: &mut std::fs::File,
1160    current_pos: u64,
1161    path: &Path,
1162    out: &mut impl Write,
1163) -> Result<u64> {
1164    file.seek(SeekFrom::Start(current_pos))
1165        .with_context(|| format!("seek error in log file {}", path.display()))?;
1166
1167    let mut buf = Vec::new();
1168    file.read_to_end(&mut buf)
1169        .with_context(|| format!("read error in log file {}", path.display()))?;
1170
1171    if !buf.is_empty() {
1172        out.write_all(&buf)
1173            .context("failed to write log output to stdout")?;
1174        out.flush().context("failed to flush stdout")?;
1175    }
1176
1177    let new_pos = current_pos + buf.len() as u64;
1178    Ok(new_pos)
1179}
1180
1181// ---------------------------------------------------------------------------
1182// Config helpers (best-effort; fall back to defaults on error).
1183// ---------------------------------------------------------------------------
1184
1185/// Load `DaemonConfig` or return an error with context.
1186fn load_daemon_config() -> Result<sqry_daemon::config::DaemonConfig> {
1187    sqry_daemon::config::DaemonConfig::load().context(
1188        "failed to load daemon config; ensure daemon.toml is well-formed or \
1189         remove it to use defaults",
1190    )
1191}
1192
1193/// Resolve the socket path from the daemon config.
1194/// Returns `None` if the config fails to load (soft failure for start
1195/// idempotency check only).
1196fn load_config_socket_path() -> Option<PathBuf> {
1197    sqry_daemon::config::DaemonConfig::load()
1198        .ok()
1199        .map(|c| c.socket_path())
1200}
1201
1202/// Resolve the daemon log file path from `config`.
1203///
1204/// Cluster-G §5.3 changed the default: a fresh install now resolves to
1205/// `<runtime_dir>/sqryd.log`. The error path below only fires when the
1206/// operator has explicitly opted out via `log_file = "stderr"` /
1207/// `log_file = "-"` (or `SQRY_DAEMON_LOG_FILE=-`). The opted-out
1208/// recovery flow lives in [`print_log_fallback_hint`] (cluster-G §5.4).
1209///
1210/// Extracted from [`run_daemon_logs`] so the opted-out failure path can
1211/// be exercised in unit tests without requiring a real daemon config on disk.
1212fn resolve_log_path(config: &sqry_daemon::config::DaemonConfig) -> Result<PathBuf> {
1213    match config.log_file.resolve() {
1214        Some(p) => Ok(p),
1215        None => {
1216            anyhow::bail!(
1217                "log_file = \"stderr\" / \"-\" — the daemon writes only to stderr.\n\
1218                 Tail systemd / journald instead (see `sqry daemon logs --help`),\n\
1219                 or remove the opt-out so the daemon logs to <runtime_dir>/sqryd.log."
1220            );
1221        }
1222    }
1223}
1224
1225/// Poll `socket_path` at [`STOP_POLL_INTERVAL_MS`] intervals until it becomes
1226/// reachable or `timeout` seconds elapse.
1227///
1228/// Returns `Ok(())` when the socket becomes reachable. Returns an error if the
1229/// deadline is reached before the socket responds.
1230///
1231/// Extracted from [`run_daemon_start`] so the timeout failure path can be
1232/// exercised in unit tests with a non-existent socket path.
1233fn poll_until_reachable(socket_path: &Path, timeout: u64) -> Result<()> {
1234    let deadline = Instant::now() + Duration::from_secs(timeout);
1235    loop {
1236        if try_connect_sync(socket_path).unwrap_or(false) {
1237            return Ok(());
1238        }
1239        if Instant::now() >= deadline {
1240            anyhow::bail!(
1241                "daemon process started but did not become reachable within {timeout} \
1242                 seconds (socket {}). Check the daemon log for startup errors.",
1243                socket_path.display()
1244            );
1245        }
1246        std::thread::sleep(Duration::from_millis(STOP_POLL_INTERVAL_MS));
1247    }
1248}
1249
1250// ---------------------------------------------------------------------------
1251// Socket connectivity probes.
1252// ---------------------------------------------------------------------------
1253
1254/// Synchronously probe whether the daemon socket is reachable.
1255///
1256/// On Unix: attempts `std::os::unix::net::UnixStream::connect`.
1257/// On Windows: checks whether the named-pipe path exists
1258/// (sufficient for readiness detection without async machinery).
1259///
1260/// Returns `Ok(true)` if the socket is reachable, `Ok(false)` if not.
1261///
1262/// # Errors
1263///
1264/// Only returns `Err` for unexpected I/O errors. `ConnectionRefused`,
1265/// `NotFound`, and `PermissionDenied` are all surfaced as `Ok(false)` —
1266/// any of them mean the daemon is not reachable from this caller (no
1267/// listener, no socket file, or kernel/sandbox blocking the connect).
1268pub fn try_connect_sync(socket_path: &Path) -> Result<bool> {
1269    #[cfg(unix)]
1270    {
1271        use std::os::unix::net::UnixStream;
1272        match UnixStream::connect(socket_path) {
1273            Ok(_) => Ok(true),
1274            Err(e) => match e.kind() {
1275                std::io::ErrorKind::ConnectionRefused
1276                | std::io::ErrorKind::NotFound
1277                | std::io::ErrorKind::PermissionDenied => Ok(false),
1278                _ => Err(anyhow::Error::from(e).context(format!(
1279                    "unexpected error probing socket {}",
1280                    socket_path.display()
1281                ))),
1282            },
1283        }
1284    }
1285    #[cfg(windows)]
1286    {
1287        // Named pipes: the pipe exists iff the path exists.
1288        Ok(socket_path.exists())
1289    }
1290    #[cfg(not(any(unix, windows)))]
1291    {
1292        let _ = socket_path;
1293        Ok(false)
1294    }
1295}
1296
1297/// Asynchronously probe whether the daemon socket is reachable.
1298/// Returns `true` if connectable, `false` otherwise. Errors are treated
1299/// as "not reachable".
1300async fn try_connect_async(socket_path: &Path) -> bool {
1301    #[cfg(unix)]
1302    {
1303        tokio::net::UnixStream::connect(socket_path).await.is_ok()
1304    }
1305    #[cfg(windows)]
1306    {
1307        socket_path.exists()
1308    }
1309    #[cfg(not(any(unix, windows)))]
1310    {
1311        let _ = socket_path;
1312        false
1313    }
1314}
1315
1316// ---------------------------------------------------------------------------
1317// Tests.
1318// ---------------------------------------------------------------------------
1319
1320#[cfg(test)]
1321mod tests {
1322    use super::*;
1323
1324    // -----------------------------------------------------------------------
1325    // resolve_sqryd_binary tests.
1326    // -----------------------------------------------------------------------
1327
1328    /// Sibling resolution: create a fake `sqryd` next to a fake `sqry` in a
1329    /// temp directory and verify that the resolver finds it.
1330    #[test]
1331    #[serial_test::serial]
1332    fn resolve_sqryd_binary_finds_sibling() {
1333        let dir = tempfile::tempdir().expect("tempdir");
1334        let sqryd_path = dir.path().join("sqryd");
1335        // Create a minimal executable file.
1336        std::fs::write(&sqryd_path, b"#!/bin/sh\n").expect("write fake sqryd");
1337        #[cfg(unix)]
1338        {
1339            use std::os::unix::fs::PermissionsExt;
1340            std::fs::set_permissions(&sqryd_path, std::fs::Permissions::from_mode(0o755))
1341                .expect("chmod");
1342        }
1343
1344        // Override current_exe would require proc-level mocking — instead
1345        // verify that the PATH fallback finds the file when SQRYD_PATH is set.
1346        unsafe {
1347            std::env::set_var("SQRYD_PATH", &sqryd_path);
1348        }
1349        let result = resolve_sqryd_binary(None);
1350        unsafe {
1351            std::env::remove_var("SQRYD_PATH");
1352        }
1353
1354        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1355        assert_eq!(result.unwrap(), sqryd_path);
1356    }
1357
1358    /// PATH fallback: unset SQRYD_PATH and verify we get a resolution
1359    /// error (no `sqryd` on PATH in a typical test environment) or a valid
1360    /// path (if sqryd happens to be installed). Either outcome is correct.
1361    #[test]
1362    #[serial_test::serial]
1363    fn resolve_sqryd_binary_falls_back_to_path() {
1364        // Remove env vars that would override PATH lookup.
1365        unsafe {
1366            std::env::remove_var("SQRYD_PATH");
1367        }
1368
1369        let result = resolve_sqryd_binary(None);
1370        // Either found on PATH or not found — both are valid in test environments.
1371        // We just assert the function returns without panicking.
1372        let _ = result;
1373    }
1374
1375    /// SQRYD_PATH env var: set it to an existing path and verify resolution.
1376    #[test]
1377    #[serial_test::serial]
1378    fn resolve_sqryd_binary_respects_env_var() {
1379        let dir = tempfile::tempdir().expect("tempdir");
1380        let sqryd_path = dir.path().join("sqryd");
1381        std::fs::write(&sqryd_path, b"#!/bin/sh\n").expect("write fake sqryd");
1382
1383        unsafe {
1384            std::env::set_var("SQRYD_PATH", &sqryd_path);
1385        }
1386        let result = resolve_sqryd_binary(None);
1387        unsafe {
1388            std::env::remove_var("SQRYD_PATH");
1389        }
1390
1391        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1392        assert_eq!(result.unwrap(), sqryd_path);
1393    }
1394
1395    // -----------------------------------------------------------------------
1396    // human_bytes tests.
1397    // -----------------------------------------------------------------------
1398
1399    #[test]
1400    fn human_bytes_formats_correctly() {
1401        assert_eq!(human_bytes(0), "0 B");
1402        assert_eq!(human_bytes(512), "512 B");
1403        assert_eq!(human_bytes(1023), "1023 B");
1404        assert_eq!(human_bytes(1024), "1 KB");
1405        assert_eq!(human_bytes(1536), "1.5 KB");
1406        assert_eq!(human_bytes(1_048_576), "1 MB");
1407        assert_eq!(human_bytes(1_073_741_824), "1 GB");
1408        assert_eq!(human_bytes(1_099_511_627_776), "1 TB");
1409        // 1.5 MB
1410        assert_eq!(human_bytes(1_572_864), "1.5 MB");
1411    }
1412
1413    // -----------------------------------------------------------------------
1414    // format_uptime tests.
1415    // -----------------------------------------------------------------------
1416
1417    #[test]
1418    fn format_uptime_renders_hours_minutes() {
1419        assert_eq!(format_uptime(0), "0m");
1420        assert_eq!(format_uptime(59), "0m");
1421        assert_eq!(format_uptime(60), "1m");
1422        assert_eq!(format_uptime(3600), "1h");
1423        assert_eq!(format_uptime(3660), "1h 1m");
1424        assert_eq!(format_uptime(7380), "2h 3m");
1425        assert_eq!(format_uptime(86400), "1d");
1426        assert_eq!(format_uptime(90061), "1d 1h 1m");
1427        assert_eq!(format_uptime(172800), "2d");
1428    }
1429
1430    // -----------------------------------------------------------------------
1431    // render_status_human tests.
1432    // -----------------------------------------------------------------------
1433
1434    /// Minimal response: empty `result` object — graceful degradation to
1435    /// `"sqryd vunknown"` with no memory or workspace sections.
1436    ///
1437    /// Verifies output is produced without panicking and contains the fallback
1438    /// `"vunknown"` version string. The `daemon_version` from `meta` is used
1439    /// when `result` has no `daemon_version`.
1440    #[test]
1441    fn daemon_status_human_renders_minimal_response() {
1442        // Matches the actual ResponseEnvelope<DaemonStatus> wire shape.
1443        // Tests graceful degradation when payload fields are absent.
1444        let envelope = serde_json::json!({
1445            "result": {},
1446            "meta": { "stale": false, "daemon_version": "8.0.6" }
1447        });
1448        let mut buf: Vec<u8> = Vec::new();
1449        render_status_human_into(&envelope, &mut buf)
1450            .expect("render_status_human_into must not fail");
1451        let output = String::from_utf8(buf).expect("rendered output must be valid UTF-8");
1452        // With empty `result`, the renderer falls back to `meta.daemon_version`.
1453        assert!(
1454            output.contains("v8.0.6"),
1455            "graceful degradation must fall back to meta.daemon_version '8.0.6'; got:\n{output}"
1456        );
1457    }
1458
1459    /// Full response: use the canonical `ResponseEnvelope<DaemonStatus>` wire
1460    /// shape from `sqry-daemon/src/workspace/status.rs`. Verifies all sections
1461    /// appear in the rendered output.
1462    #[test]
1463    fn daemon_status_human_renders_full_response() {
1464        // This matches the actual JSON-RPC `result` returned by
1465        // `DaemonClient::status()`, which is a ResponseEnvelope<DaemonStatus>.
1466        let envelope = serde_json::json!({
1467            "result": {
1468                "daemon_version": "8.0.6",
1469                "uptime_seconds": 8040_u64,
1470                "memory": {
1471                    "limit_bytes":       2_147_483_648_u64,
1472                    "current_bytes":       471_859_200_u64,
1473                    "reserved_bytes":                0_u64,
1474                    "high_water_bytes":  1_288_490_188_u64
1475                },
1476                "workspaces": [
1477                    {
1478                        "index_root": "/home/user/repos/main-project",
1479                        "state": "Loaded",
1480                        "pinned": true,
1481                        "current_bytes":   335_544_320_u64,
1482                        "high_water_bytes": 933_232_896_u64,
1483                        "last_good_at": null,
1484                        "last_error": null,
1485                        "retry_count": 0
1486                    },
1487                    {
1488                        "index_root": "/home/user/repos/auth-service",
1489                        "state": "Loaded",
1490                        "pinned": false,
1491                        "current_bytes":    83_886_080_u64,
1492                        "high_water_bytes": 324_534_016_u64,
1493                        "last_good_at": null,
1494                        "last_error": null,
1495                        "retry_count": 0
1496                    }
1497                ]
1498            },
1499            "meta": {
1500                "stale": false,
1501                "daemon_version": "8.0.6"
1502            }
1503        });
1504        let mut buf: Vec<u8> = Vec::new();
1505        render_status_human_into(&envelope, &mut buf)
1506            .expect("render_status_human_into must not fail");
1507        let output = String::from_utf8(buf).expect("rendered output must be valid UTF-8");
1508        // Version and uptime header.
1509        assert!(
1510            output.contains("v8.0.6"),
1511            "must contain version; got:\n{output}"
1512        );
1513        assert!(
1514            output.contains("2h"),
1515            "must contain uptime hours; got:\n{output}"
1516        );
1517        // Memory section.
1518        assert!(
1519            output.contains("Memory:"),
1520            "must contain memory section; got:\n{output}"
1521        );
1522        assert!(
1523            output.contains("2 GB"),
1524            "must contain limit '2 GB'; got:\n{output}"
1525        );
1526        // Workspaces section — both index_root paths must appear.
1527        assert!(
1528            output.contains("Workspaces"),
1529            "must contain workspaces section; got:\n{output}"
1530        );
1531        assert!(
1532            output.contains("main-project"),
1533            "must contain workspace path; got:\n{output}"
1534        );
1535        assert!(
1536            output.contains("auth-service"),
1537            "must contain workspace path; got:\n{output}"
1538        );
1539    }
1540
1541    /// Verify that render_status_human_into extracts `daemon_version` and
1542    /// `uptime_seconds` from the canonical `result` key, and that the rendered
1543    /// output contains those values verbatim.
1544    ///
1545    /// This test captures rendered output into a `Vec<u8>` buffer via
1546    /// [`render_status_human_into`] so a regression to old field names (e.g.
1547    /// `version`, `uptime_secs`) would cause the assertions to fail even though
1548    /// the JSON fixture would still parse correctly.
1549    ///
1550    /// **Sentinel distinctness**: `result.daemon_version` ("8.1.2") and
1551    /// `meta.daemon_version` ("7.9.0") are intentionally different so that a
1552    /// renderer that accidentally falls back to the `meta` copy would produce
1553    /// "v7.9.0" in the header instead of "v8.1.2", causing the assertion to fail.
1554    #[test]
1555    fn daemon_status_human_extracts_version_and_uptime() {
1556        let envelope = serde_json::json!({
1557            "result": {
1558                "daemon_version": "8.1.2",
1559                "uptime_seconds": 3661_u64,
1560                "memory": {
1561                    "limit_bytes": 1_073_741_824_u64,
1562                    "current_bytes": 104_857_600_u64,
1563                    "reserved_bytes": 0_u64,
1564                    "high_water_bytes": 209_715_200_u64
1565                },
1566                "workspaces": []
1567            },
1568            // meta.daemon_version is intentionally different from result.daemon_version
1569            // to detect regressions where the renderer uses the meta copy instead.
1570            "meta": { "stale": false, "daemon_version": "7.9.0" }
1571        });
1572
1573        let mut buf: Vec<u8> = Vec::new();
1574        render_status_human_into(&envelope, &mut buf)
1575            .expect("render_status_human_into must not fail writing to Vec");
1576        let output = String::from_utf8(buf).expect("rendered output must be valid UTF-8");
1577
1578        // Header line must contain the daemon version from `result.daemon_version`,
1579        // NOT the stale value from `meta.daemon_version`.
1580        assert!(
1581            output.contains("v8.1.2"),
1582            "rendered output must contain result.daemon_version '8.1.2'; got:\n{output}"
1583        );
1584        assert!(
1585            !output.contains("v7.9.0"),
1586            "rendered output must NOT contain meta.daemon_version '7.9.0'; got:\n{output}"
1587        );
1588        // Header line must contain the uptime formatted from `result.uptime_seconds` (3661s = 1h 1m).
1589        assert!(
1590            output.contains("1h 1m"),
1591            "rendered output must contain uptime '1h 1m'; got:\n{output}"
1592        );
1593        // Memory section: current_bytes (104 MB) and limit_bytes (1 GB) must appear.
1594        assert!(
1595            output.contains("100 MB"),
1596            "rendered output must contain memory current '100 MB'; got:\n{output}"
1597        );
1598        assert!(
1599            output.contains("1 GB"),
1600            "rendered output must contain memory limit '1 GB'; got:\n{output}"
1601        );
1602        // Memory section: high_water_bytes (200 MB peak) must appear.
1603        assert!(
1604            output.contains("200 MB"),
1605            "rendered output must contain memory peak '200 MB'; got:\n{output}"
1606        );
1607    }
1608
1609    /// Verify that render_workspace_line_into uses `index_root` /
1610    /// `current_bytes` / `high_water_bytes` — the canonical WorkspaceStatus
1611    /// field names — and that those values appear in the rendered output.
1612    ///
1613    /// This test captures rendered output into a `Vec<u8>` buffer via
1614    /// [`render_workspace_line_into`] so a regression to old field names (e.g.
1615    /// `path`, `memory_bytes`) would cause the assertions to fail.
1616    #[test]
1617    fn daemon_status_human_renders_workspace_canonical_fields() {
1618        let ws = serde_json::json!({
1619            "index_root": "/home/user/repos/sqry",
1620            "state": "Loaded",
1621            "pinned": true,
1622            "current_bytes": 335_544_320_u64,
1623            "high_water_bytes": 671_088_640_u64,
1624            "last_good_at": null,
1625            "last_error": null,
1626            "retry_count": 0
1627        });
1628
1629        let mut buf: Vec<u8> = Vec::new();
1630        render_workspace_line_into(&ws, &mut buf)
1631            .expect("render_workspace_line_into must not fail writing to Vec");
1632        let output = String::from_utf8(buf).expect("rendered output must be valid UTF-8");
1633
1634        // The path from `index_root` must appear (possibly tilde-shortened, but
1635        // the last component "sqry" is always preserved).
1636        assert!(
1637            output.contains("sqry"),
1638            "rendered output must contain the workspace path component 'sqry'; got:\n{output}"
1639        );
1640        // current_bytes (320 MB) must appear.
1641        assert!(
1642            output.contains("320 MB"),
1643            "rendered output must contain workspace size '320 MB' from current_bytes; got:\n{output}"
1644        );
1645        // high_water_bytes (640 MB) must appear.
1646        assert!(
1647            output.contains("640 MB"),
1648            "rendered output must contain workspace peak '640 MB' from high_water_bytes; got:\n{output}"
1649        );
1650        // The "pinned" tag must appear (from the `pinned: true` field).
1651        assert!(
1652            output.contains("pinned"),
1653            "rendered output must contain 'pinned' tag; got:\n{output}"
1654        );
1655    }
1656
1657    // -----------------------------------------------------------------------
1658    // resolve_log_path tests.
1659    // -----------------------------------------------------------------------
1660
1661    /// Cluster-G §5.3 changed the default: `DaemonConfig::default()` now
1662    /// returns `LogFileSetting::Path(<runtime_dir>/sqryd.log)`, so
1663    /// `resolve_log_path` resolves successfully without operator
1664    /// configuration. The error path is exercised only when the
1665    /// operator explicitly opts out (`log_file = "stderr"`) — see the
1666    /// `resolve_log_path_errors_when_log_file_opted_out` test below.
1667    #[test]
1668    fn resolve_log_path_returns_runtime_dir_default_when_unconfigured() {
1669        let config = sqry_daemon::config::DaemonConfig::default();
1670        let result = resolve_log_path(&config).expect("default config must resolve to a path");
1671        assert!(
1672            result.ends_with("sqryd.log"),
1673            "default log path should end with sqryd.log, got: {}",
1674            result.display()
1675        );
1676    }
1677
1678    /// Operator opt-out via `log_file = "stderr"` is the only path that
1679    /// should still surface the legacy "no log file" error message
1680    /// (cluster-G §5.4). The error must reference `stderr` so the user
1681    /// knows why no file is available.
1682    #[test]
1683    fn resolve_log_path_errors_when_log_file_opted_out() {
1684        let mut config = sqry_daemon::config::DaemonConfig::default();
1685        config.log_file = sqry_daemon::config::LogFileSetting::Special("stderr".to_string());
1686
1687        let result = resolve_log_path(&config);
1688        assert!(
1689            result.is_err(),
1690            "resolve_log_path must return Err when log_file is Special"
1691        );
1692
1693        let err_msg = format!("{}", result.unwrap_err());
1694        assert!(
1695            err_msg.contains("stderr"),
1696            "error must mention 'stderr' to explain the opt-out; got:\n{err_msg}"
1697        );
1698    }
1699
1700    // -----------------------------------------------------------------------
1701    // poll_until_reachable tests.
1702    // -----------------------------------------------------------------------
1703
1704    /// When the target socket does not exist and the timeout is zero, the
1705    /// first iteration must already find `Instant::now() >= deadline` and bail
1706    /// immediately with the expected timeout error message.
1707    ///
1708    /// This directly tests the m-1 fix from iter-0: `run_daemon_start` must
1709    /// honour the `--timeout` parameter and surface an actionable error when
1710    /// the daemon does not become reachable within the budget.
1711    #[test]
1712    fn poll_until_reachable_times_out_for_unreachable_socket() {
1713        let dir = tempfile::tempdir().expect("tempdir");
1714        let socket_path = dir.path().join("nonexistent.sock");
1715
1716        // timeout = 0 → deadline is already in the past on the first check.
1717        let result = poll_until_reachable(&socket_path, 0);
1718        assert!(
1719            result.is_err(),
1720            "poll_until_reachable must return Err when socket is unreachable and timeout = 0"
1721        );
1722
1723        let err_msg = format!("{}", result.unwrap_err());
1724        assert!(
1725            err_msg.contains("did not become reachable"),
1726            "error must contain 'did not become reachable'; got:\n{err_msg}"
1727        );
1728        // The error message must include the socket path for diagnostics.
1729        assert!(
1730            err_msg.contains("nonexistent.sock"),
1731            "error must contain the socket path; got:\n{err_msg}"
1732        );
1733    }
1734
1735    // -----------------------------------------------------------------------
1736    // try_auto_start_daemon disabled test.
1737    // -----------------------------------------------------------------------
1738
1739    /// When SQRY_DAEMON_AUTO_START is not set (or set to a value other than
1740    /// "1"), try_auto_start_daemon must return Ok(false) immediately.
1741    #[test]
1742    #[serial_test::serial]
1743    fn try_auto_start_daemon_returns_false_when_disabled() {
1744        unsafe {
1745            std::env::remove_var(ENV_DAEMON_AUTO_START);
1746        }
1747        let result = try_auto_start_daemon().expect("try_auto_start_daemon must not error");
1748        assert!(
1749            !result,
1750            "expected false when SQRY_DAEMON_AUTO_START is unset"
1751        );
1752
1753        // Also verify with a non-"1" value.
1754        unsafe {
1755            std::env::set_var(ENV_DAEMON_AUTO_START, "0");
1756        }
1757        let result = try_auto_start_daemon().expect("try_auto_start_daemon must not error");
1758        unsafe {
1759            std::env::remove_var(ENV_DAEMON_AUTO_START);
1760        }
1761        assert!(!result, "expected false when SQRY_DAEMON_AUTO_START=0");
1762    }
1763}