Skip to main content

trusty_console/
lib.rs

1//! trusty-console library entry point.
2//!
3//! Why: Expose the console daemon's startup sequence as a public `run()`
4//! function so bundled shim binaries inside host crates (trusty-search,
5//! trusty-memory, trusty-analyze, trusty-review, trusty-mpm) can call
6//! `trusty_console::run()` without duplicating any logic. This mirrors the
7//! exact pattern used by trusty-embedderd (bundled into trusty-search via
8//! issue #187) and trusty-bm25-daemon (bundled into trusty-memory via PR #190).
9//! What: Re-exports all public submodules and provides `run_from(argv)` as the
10//! canonical library entry point that parses an explicit argv vector and
11//! dispatches to subcommands; `run()` is a thin wrapper passing the process's
12//! global argv.
13//! Test: `cargo test -p trusty-console` exercises the CLI parsing tests defined
14//! in the submodules.
15
16use std::sync::Arc;
17use std::time::Duration;
18
19use anyhow::{Context, Result};
20use clap::{Parser, Subcommand};
21use tracing::info;
22use trusty_common::{init_tracing, shutdown_signal, write_daemon_addr};
23
24/// Default console HTTP bind address (used for both serve and `port` reporting).
25///
26/// Why: A single constant keeps the serve default and the `port` verb's
27/// fallback in lock-step so `trusty-installer` (`tctl`) never discovers a port
28/// the console would not actually bind to.
29/// What: `127.0.0.1:7788` — the canonical localhost console address.
30/// Test: `test_resolve_reported_addr_default` asserts the `port` verb falls
31/// back to this value's host/port when no discovery file is present.
32pub const DEFAULT_HTTP: &str = "127.0.0.1:7788";
33
34/// Default console port, parsed once from [`DEFAULT_HTTP`].
35///
36/// Why: The `port` verb reports this when no running console has written a
37/// discovery file yet.
38/// What: `7788`.
39/// Test: covered by the `port` verb tests below.
40pub const DEFAULT_PORT: u16 = 7788;
41
42pub mod bind;
43pub mod connector;
44pub mod detect;
45pub mod mcp_handle;
46pub mod metrics_poller;
47pub mod poller;
48pub mod proxy;
49pub mod routes;
50pub mod server;
51pub(crate) mod url_util;
52
53// ─── CLI ─────────────────────────────────────────────────────────────────────
54
55/// trusty-console: web dashboard for trusty services.
56///
57/// Why: Provides a single entry point for all console subcommands so future
58/// phases (status, doctor, open) can be added without breaking existing usage.
59/// What: Parses top-level arguments and delegates to subcommand handlers.
60/// Test: `cargo run -p trusty-console -- serve --help` must succeed.
61#[derive(Debug, Parser)]
62#[command(
63    name = "trusty-console",
64    version,
65    about = "Web dashboard for trusty services"
66)]
67pub struct Cli {
68    #[command(subcommand)]
69    pub command: Commands,
70}
71
72/// Available subcommands.
73///
74/// Why: `serve` runs the dashboard; `port` is a non-serving contract verb that
75/// reports the console's bound (or default) port so orchestrators like
76/// `trusty-installer` (`tctl`) can discover/launch the console without parsing logs.
77/// What: Clap enum; each variant carries its own args.
78/// Test: Subcommand selection tested via `Cli::parse_from`.
79#[derive(Debug, Subcommand)]
80pub enum Commands {
81    /// Start the HTTP server and serve the console dashboard.
82    Serve(ServeArgs),
83    /// Report the console's bound (or default) HTTP port and exit.
84    Port(PortArgs),
85}
86
87/// Arguments for `trusty-console port`.
88///
89/// Why: `trusty-installer` (`tctl`) (the orchestrator, issue #1316) discovers
90/// the console URL by spawning `trusty-console port --json` and parsing the
91/// `{addr,port}` envelope (see trusty-installer `os_env.rs`). The verb must
92/// exist and be machine-readable for that discovery to work after the console is
93/// de-bundled from the host crates (#1318).
94/// What: A single `--json` flag selecting JSON output (default is a single
95/// human-readable port line).
96/// Test: `test_port_args_json_flag` / `test_port_args_default` below.
97#[derive(Debug, Parser)]
98pub struct PortArgs {
99    /// Emit a JSON envelope `{"addr":"<host>","port":<u16>}` instead of a bare
100    /// port number. Consumed by `trusty-installer` (`tctl`) console discovery.
101    #[arg(long, default_value_t = false)]
102    pub json: bool,
103}
104
105/// Arguments for `trusty-console serve`.
106///
107/// Why: The bind address must be configurable so users can change the port when
108/// 7788 is taken; `--open` is a convenience for developers; `--poll-interval`
109/// lets operators tune the background health-poll frequency; `--tailscale`
110/// enables durable tailnet exposure without requiring `--http 0.0.0.0`.
111/// What: Optional `--http` (default `127.0.0.1:7788`), `--open`,
112/// `--poll-interval`, and `--tailscale` flags.
113/// Env overrides: `TRUSTY_CONSOLE_BIND` sets the default bind mode so a
114/// supervised/relaunched daemon stays tailnet-reachable without extra flags.
115/// Test: Default address tested in `test_serve_args_defaults` below.
116#[derive(Debug, Parser)]
117pub struct ServeArgs {
118    /// Address to listen on (default: 127.0.0.1:7788).
119    ///
120    /// Takes precedence over --tailscale and TRUSTY_CONSOLE_BIND when set to a
121    /// non-default value.
122    #[arg(long, default_value = "127.0.0.1:7788")]
123    pub http: String,
124
125    /// Expose the console on both 127.0.0.1 and the machine's Tailscale IPv4,
126    /// enabling tailnet clients to reach the console without LAN exposure.
127    ///
128    /// The Tailscale IP is detected via `tailscale ip -4`. If Tailscale is not
129    /// running, prints a warning and falls back to localhost-only.
130    ///
131    /// Can also be set persistently via the TRUSTY_CONSOLE_BIND=tailscale env
132    /// var so a supervised/relaunched console stays tailnet-reachable without
133    /// manually passing this flag.
134    #[arg(long, default_value_t = false)]
135    pub tailscale: bool,
136
137    /// Open the console in the default browser after starting.
138    #[arg(long, default_value_t = false)]
139    pub open: bool,
140
141    /// Background poll interval in seconds (default: 15).
142    ///
143    /// Controls BOTH the health poller (`poller::start`) AND the metrics
144    /// poller (`metrics_poller::start`). Increasing this value reduces the
145    /// frequency of both the HTTP health checks against each connector AND
146    /// the stdio MCP `console_metrics` tool calls against trusty-analyze.
147    #[arg(long, default_value_t = 15u64)]
148    pub poll_interval: u64,
149}
150
151// ─── public entry point ────────────────────────────────────────────────────
152
153/// Library entry point for the trusty-console daemon, using the process argv.
154///
155/// Why: The standalone `trusty-console` crate is now the SOLE producer of the
156/// `trusty-console` binary (#1318 — de-bundled from the 5 host crates). The
157/// thin `main.rs` calls this, which simply forwards the process's global argv
158/// to `run_from`.
159/// What: Collects `std::env::args()` and delegates to [`run_from`].
160/// Test: Indirectly via the `run_from` tests below and the binary smoke test.
161pub async fn run() -> Result<()> {
162    run_from(std::env::args().collect()).await
163}
164
165/// Library entry point parameterised on an explicit argv vector.
166///
167/// Why: Decoupling argument parsing from the process's global argv (#1318)
168/// lets callers (tests, future embedders) drive the console deterministically
169/// without mutating `std::env`. Previously `run()` called `Cli::parse()`,
170/// which read global argv and could not be exercised in isolation.
171/// What: Initialises tracing, parses `argv` via `Cli::parse_from`, and
172/// dispatches to the matching subcommand handler. `argv[0]` is the program
173/// name (clap convention). Returns `Ok(())` after clean shutdown.
174/// Test: `test_run_from_port_json_outputs_envelope` drives this directly with
175/// a synthetic argv; integration via `cargo test -p trusty-console`.
176pub async fn run_from(argv: Vec<String>) -> Result<()> {
177    init_tracing(1);
178
179    let cli = Cli::parse_from(argv);
180
181    match cli.command {
182        Commands::Serve(args) => run_serve(args).await,
183        Commands::Port(args) => run_port(args),
184    }
185}
186
187/// Resolve the console's reportable HTTP address (host, port).
188///
189/// Why: The `port` verb must report the LIVE port of a running console when
190/// one exists, falling back to the default otherwise — so `trusty-installer`
191/// (`tctl`) discovery (issue #1316) points at the real dashboard, not a guess.
192/// What: Reads the `trusty-console` discovery file via
193/// `trusty_common::read_daemon_addr`; on a parseable `host:port` returns that
194/// pair, else falls back to ([`DEFAULT_HTTP`] host, [`DEFAULT_PORT`]). Never
195/// errors — discovery failures degrade to the default.
196/// Test: `test_resolve_reported_addr_default` (no file → default).
197pub fn resolve_reported_addr() -> (String, u16) {
198    if let Ok(Some(recorded)) = trusty_common::read_daemon_addr("trusty-console")
199        && let Ok(sa) = recorded.parse::<std::net::SocketAddr>()
200    {
201        return (sa.ip().to_string(), sa.port());
202    }
203    let default_host = DEFAULT_HTTP
204        .rsplit_once(':')
205        .map(|(h, _)| h.to_owned())
206        .unwrap_or_else(|| "127.0.0.1".to_owned());
207    (default_host, DEFAULT_PORT)
208}
209
210/// Run the `port` subcommand: print the console's bound/default port and exit.
211///
212/// Why: `trusty-installer` (`tctl`) console discovery spawns
213/// `trusty-console port --json` and parses a `{addr,port}` envelope
214/// (trusty-installer `os_env.rs`). This verb is the contract that makes that
215/// discovery work; without it the call exits non-zero and console discovery is
216/// silently broken (the latent bug fixed by #1318).
217/// What: Resolves the reportable address; with `--json` prints
218/// `{"addr":"<host>","port":<u16>}` to stdout, otherwise prints the bare port.
219/// Returns `Ok(())`.
220/// Test: `test_run_from_port_json_outputs_envelope`,
221/// `test_port_envelope_is_valid_json`.
222pub fn run_port(args: PortArgs) -> Result<()> {
223    let (addr, port) = resolve_reported_addr();
224    if args.json {
225        let envelope = serde_json::json!({ "addr": addr, "port": port });
226        println!("{envelope}");
227    } else {
228        println!("{port}");
229    }
230    Ok(())
231}
232
233/// Run the `serve` subcommand.
234///
235/// Why: Separating the serve logic from `run()` keeps `run()` thin and allows
236/// this function to be called from integration tests.
237/// What: Resolves bind addresses (respecting `--tailscale`, `--http`, and
238/// `TRUSTY_CONSOLE_BIND`), builds the router, binds TCP listener(s), writes
239/// the discovery file, starts the background health-poll task, optionally opens
240/// a browser, then serves until SIGTERM/SIGINT with graceful shutdown.
241/// Additional addresses beyond the primary get their own spawned `axum::serve`
242/// task that runs concurrently until the shared shutdown signal fires.
243/// Test: Server integration tests in `server.rs` cover the router directly
244/// without exercising this function (to avoid real TCP binding in unit tests).
245pub async fn run_serve(args: ServeArgs) -> Result<()> {
246    // ── resolve bind mode ───────────────────────────────────────────────────
247    let mode = bind::BindMode::from_env_and_flags(&args.http, DEFAULT_HTTP, args.tailscale);
248    let port = bind::port_from_addr(&args.http, DEFAULT_PORT);
249    let addrs = bind::resolve_bind_addrs(&mode, port, bind::detect_tailscale_ipv4);
250
251    // ── service setup ───────────────────────────────────────────────────────
252    let connectors = detect::all_connectors();
253    let state = server::AppState::new(connectors);
254
255    // Kick off an eager first poll so the cache is warm before the first
256    // HTTP request arrives.
257    {
258        let cache = state.poller_cache().clone();
259        let c = state.connectors();
260        cache.poll_once(c).await;
261    }
262
263    // Start the background poller that refreshes the cache on the configured
264    // interval.
265    poller::start(
266        state.poller_cache().clone(),
267        state.connectors(),
268        Duration::from_secs(args.poll_interval),
269    );
270
271    // ── metrics MCP poll (trusty-analyze) ───────────────────────────────────
272    // The analyze handle is stored in AppState so on-demand routes
273    // (/api/console/metrics/analyze/indexes, /api/console/metrics/analyze/visualize)
274    // share the same child process. Here we hand a clone of that Arc to the
275    // background metrics poller so both paths reuse one stdio connection.
276    //
277    // Why "mcp" not "serve --mcp":
278    // `serve --mcp` starts BOTH the HTTP daemon and an MCP stdio loop; it
279    // requires trusty-search to be reachable at startup and tries to open the
280    // redb facts store (which may already be locked by the running daemon).
281    // `mcp` only runs a pure stdio bridge pointing at the running HTTP daemon;
282    // if the HTTP daemon is not yet up, `ensure_mcp_daemon_up` in analyze's
283    // `mcp` subcommand starts it automatically. This is the correct invocation
284    // for a lightweight stdio-only console_metrics child.
285    metrics_poller::start(
286        state.analyze_handle(),
287        state.metrics_cache().clone(),
288        Duration::from_secs(args.poll_interval),
289    );
290
291    // ── metrics MCP poll (trusty-memory) ────────────────────────────────────
292    // trusty-memory's stdio MCP mode is `serve --stdio` (see main.rs).
293    // The bridge forwards all JSON-RPC calls to the running HTTP daemon and
294    // auto-starts it if absent. On machines without trusty-memory the handle
295    // marks it Absent immediately; the cache stays None;
296    // /api/console/metrics/memory returns 503 (graceful degradation).
297    //
298    // The handle comes from AppState::mcp_handles so the services route and
299    // the metrics poller share the same McpServiceHandle (and thus the same
300    // tools/list probe result — once the probe marks the handle Degraded,
301    // that state is visible to both paths without a second probe).
302    {
303        let handles = state.mcp_handles();
304        if let Some(h) = handles.get("trusty-memory") {
305            metrics_poller::start(
306                Arc::clone(h),
307                state.memory_metrics_cache().clone(),
308                Duration::from_secs(args.poll_interval),
309            );
310        } else {
311            tracing::warn!(
312                service = "trusty-memory",
313                "run_serve: no MCP handle registered for trusty-memory — \
314                 metrics poller will not start for this service"
315            );
316        }
317    }
318
319    // ── metrics MCP poll (trusty-search) ────────────────────────────────────
320    // trusty-search's stdio MCP mode is `serve` (see serve_stdio in main.rs).
321    // On machines without trusty-search the handle marks it Absent immediately;
322    // the cache stays None; /api/console/metrics/search returns 503.
323    //
324    // Same shared-handle pattern as trusty-memory above.
325    {
326        let handles = state.mcp_handles();
327        if let Some(h) = handles.get("trusty-search") {
328            metrics_poller::start(
329                Arc::clone(h),
330                state.search_metrics_cache().clone(),
331                Duration::from_secs(args.poll_interval),
332            );
333        } else {
334            tracing::warn!(
335                service = "trusty-search",
336                "run_serve: no MCP handle registered for trusty-search — \
337                 metrics poller will not start for this service"
338            );
339        }
340    }
341
342    // ── metrics MCP poll (trusty-review) ────────────────────────────────────
343    // trusty-review's stdio MCP mode is `serve --stdio` (see commands/serve.rs).
344    // When in stdio mode, trusty-review does NOT start an HTTP daemon — it runs
345    // a pure MCP JSON-RPC loop over stdin/stdout, connected to the LLM directly.
346    // This is the correct invocation for the console's lightweight metrics poll.
347    // On machines without trusty-review the handle marks it Absent immediately;
348    // the cache stays None; /api/console/metrics/review returns 503.
349    //
350    // Same shared-handle pattern as trusty-memory and trusty-search above.
351    {
352        let handles = state.mcp_handles();
353        if let Some(h) = handles.get("trusty-review") {
354            metrics_poller::start(
355                Arc::clone(h),
356                state.review_metrics_cache().clone(),
357                Duration::from_secs(args.poll_interval),
358            );
359        } else {
360            tracing::warn!(
361                service = "trusty-review",
362                "run_serve: no MCP handle registered for trusty-review — \
363                 metrics poller will not start for this service"
364            );
365        }
366    }
367
368    // ── metrics MCP poll (trusty-mpm) ───────────────────────────────────────
369    // trusty-mpm's stdio MCP mode is `serve --stdio` (the #1221 bridge that
370    // auto-starts the durable daemon and forwards JSON-RPC to its loopback
371    // POST /rpc). The console_metrics poll keeps the coarse session-fleet +
372    // supervisor health cache warm for /api/console/metrics/mpm; the Sessions
373    // tab itself polls /api/console/sessions live at a faster cadence (#1222).
374    // On machines without trusty-mpm the handle marks it Absent immediately; the
375    // cache stays None; /api/console/metrics/mpm returns 503 (graceful).
376    {
377        let handles = state.mcp_handles();
378        if let Some(h) = handles.get("trusty-mpm") {
379            metrics_poller::start(
380                Arc::clone(h),
381                state.mpm_metrics_cache().clone(),
382                Duration::from_secs(args.poll_interval),
383            );
384        } else {
385            tracing::warn!(
386                service = "trusty-mpm",
387                "run_serve: no MCP handle registered for trusty-mpm — \
388                 metrics poller will not start for this service"
389            );
390        }
391    }
392
393    let router = server::build_router(state.clone());
394
395    // ── bind primary listener ───────────────────────────────────────────────
396    let primary_addr = *addrs.first().context("bind address list is empty")?;
397    let primary_listener = bind::bind_listener(primary_addr).await?;
398    let primary_local = primary_listener.local_addr().context("get local addr")?;
399    let addr_string = primary_local.to_string();
400    info!("trusty-console listening on http://{primary_local}");
401
402    // ── bind additional listeners (Tailscale mode: secondary addr) ──────────
403    for &extra_addr in addrs.get(1..).unwrap_or(&[]) {
404        let extra_listener = bind::bind_listener(extra_addr).await?;
405        let extra_local = extra_listener
406            .local_addr()
407            .context("get extra local addr")?;
408        info!("trusty-console also listening on http://{extra_local}");
409        eprintln!("trusty-console (tailnet): http://{extra_local}");
410        let r = router.clone();
411        tokio::spawn(async move {
412            if let Err(e) = axum::serve(extra_listener, r)
413                .with_graceful_shutdown(trusty_common::shutdown_signal())
414                .await
415            {
416                tracing::warn!("extra listener {extra_local} exited: {e}");
417            }
418        });
419    }
420
421    // ── write discovery file (primary address) ──────────────────────────────
422    // Best-effort: log a warning on failure but do not abort the serve.
423    if let Err(e) = write_daemon_addr("trusty-console", &addr_string) {
424        tracing::warn!("could not write trusty-console discovery file: {e}");
425    }
426
427    let console_url = format!("http://{primary_local}");
428    eprintln!("trusty-console: {console_url}");
429
430    if args.open {
431        // Best-effort browser open; ignore errors.
432        let _ = open::that(&console_url);
433    }
434
435    axum::serve(primary_listener, router)
436        .with_graceful_shutdown(shutdown_signal())
437        .await
438        .context("server error")?;
439
440    // Best-effort removal of the discovery file on clean shutdown.
441    // Only remove the file if it still points to our address; another
442    // instance may have already written a new one.
443    //
444    // RESIDUAL RACE: the read → compare → delete sequence is not atomic. A
445    // second instance could write a new address between our read and our
446    // remove_file, causing us to delete a file we should not. The window is
447    // tiny (milliseconds) and the consequence is cosmetic (a stale `port`
448    // invocation returns the default rather than the live address). No
449    // behavior change is required — this comment documents the known race.
450    if let Ok(Some(recorded)) = trusty_common::read_daemon_addr("trusty-console")
451        && recorded == addr_string
452        && let Ok(dir) = trusty_common::resolve_data_dir("trusty-console")
453    {
454        let _ = std::fs::remove_file(dir.join("http_addr"));
455    }
456
457    Ok(())
458}
459
460// ─── tests ───────────────────────────────────────────────────────────────────
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    /// Serialises tests that mutate the `TRUSTY_DATA_DIR_OVERRIDE` env var.
467    ///
468    /// Why: `std::env::set_var`/`remove_var` are process-global; parallel test
469    /// threads racing on them cause flaky failures. A module-level mutex makes
470    /// the override-set / call / override-clear sequence atomic per test.
471    /// What: a `()` mutex acquired at the top of each env-mutating test.
472    /// Test: used by the `port` verb tests below.
473    static DATA_DIR_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
474
475    /// Why: default http address must be 127.0.0.1:7788 and tailscale off.
476    /// What: parses `serve` with no flags and checks all defaults.
477    /// Test: this test itself.
478    #[test]
479    fn test_serve_args_defaults() {
480        let cli = Cli::parse_from(["trusty-console", "serve"]);
481        match cli.command {
482            Commands::Serve(args) => {
483                assert_eq!(args.http, "127.0.0.1:7788");
484                assert!(!args.open);
485                assert!(!args.tailscale);
486                assert_eq!(args.poll_interval, 15);
487            }
488            other => panic!("expected Serve, got {other:?}"),
489        }
490    }
491
492    /// Why: --tailscale flag must be parsed correctly.
493    /// What: parses `serve --tailscale`; asserts tailscale=true.
494    /// Test: this test itself.
495    #[test]
496    fn test_serve_args_tailscale_flag() {
497        let cli = Cli::parse_from(["trusty-console", "serve", "--tailscale"]);
498        match cli.command {
499            Commands::Serve(args) => {
500                assert!(args.tailscale);
501                assert_eq!(args.http, "127.0.0.1:7788");
502            }
503            other => panic!("expected Serve, got {other:?}"),
504        }
505    }
506
507    /// Why: custom --http flag must override the default.
508    /// What: parses `serve --http 0.0.0.0:9000`.
509    /// Test: this test itself.
510    #[test]
511    fn test_serve_args_custom_http() {
512        let cli = Cli::parse_from(["trusty-console", "serve", "--http", "0.0.0.0:9000"]);
513        match cli.command {
514            Commands::Serve(args) => {
515                assert_eq!(args.http, "0.0.0.0:9000");
516            }
517            other => panic!("expected Serve, got {other:?}"),
518        }
519    }
520
521    /// Why: --poll-interval must override the default.
522    /// What: parses `serve --poll-interval 30`.
523    /// Test: this test itself.
524    #[test]
525    fn test_serve_args_custom_poll_interval() {
526        let cli = Cli::parse_from(["trusty-console", "serve", "--poll-interval", "30"]);
527        match cli.command {
528            Commands::Serve(args) => {
529                assert_eq!(args.poll_interval, 30);
530            }
531            other => panic!("expected Serve, got {other:?}"),
532        }
533    }
534
535    /// Why: the `port` subcommand must parse with a default (non-JSON) form so
536    /// the bare-port output path is reachable.
537    /// What: parses `port` and asserts `--json` defaults to false.
538    /// Test: this test itself.
539    #[test]
540    fn test_port_args_default() {
541        let cli = Cli::parse_from(["trusty-console", "port"]);
542        match cli.command {
543            Commands::Port(args) => assert!(!args.json),
544            other => panic!("expected Port, got {other:?}"),
545        }
546    }
547
548    /// Why: `trusty-installer` (`tctl`) invokes `trusty-console port --json`; the flag must parse.
549    /// What: parses `port --json` and asserts `json == true`.
550    /// Test: this test itself.
551    #[test]
552    fn test_port_args_json_flag() {
553        let cli = Cli::parse_from(["trusty-console", "port", "--json"]);
554        match cli.command {
555            Commands::Port(args) => assert!(args.json),
556            other => panic!("expected Port, got {other:?}"),
557        }
558    }
559
560    /// Why: when no console has written a discovery file, the reported port
561    /// must fall back to the canonical default so `trusty-installer` (`tctl`)
562    /// still gets a usable address.
563    /// What: calls `resolve_reported_addr` under an isolated data dir (no
564    /// discovery file present) and asserts the default host/port.
565    /// Test: this test itself; uses the data-dir override env to avoid reading
566    /// a real running console's file.
567    #[test]
568    fn test_resolve_reported_addr_default() {
569        let _guard = DATA_DIR_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
570        let tmp = std::env::temp_dir().join(format!(
571            "trusty-console-port-test-{}-{}",
572            std::process::id(),
573            std::time::SystemTime::now()
574                .duration_since(std::time::UNIX_EPOCH)
575                .map(|d| d.as_nanos())
576                .unwrap_or(0)
577        ));
578        std::fs::create_dir_all(&tmp).expect("create temp data dir");
579        // SAFETY: guarded by data_dir_test_lock to serialise env mutation.
580        unsafe {
581            std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, &tmp);
582        }
583        let (addr, port) = resolve_reported_addr();
584        unsafe {
585            std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
586        }
587        assert_eq!(addr, "127.0.0.1");
588        assert_eq!(port, DEFAULT_PORT);
589    }
590
591    /// Why: the JSON envelope emitted by `run_port` must be valid JSON with the
592    /// `addr` and `port` keys that `trusty-installer` (`tctl`) `parse_console_port` consumes.
593    /// What: builds the same envelope `run_port` prints and round-trips it
594    /// through serde to assert structure.
595    /// Test: this test itself.
596    #[test]
597    fn test_port_envelope_is_valid_json() {
598        let envelope = serde_json::json!({ "addr": "127.0.0.1", "port": DEFAULT_PORT });
599        let s = envelope.to_string();
600        let v: serde_json::Value = serde_json::from_str(&s).expect("valid json");
601        assert_eq!(v.get("addr").and_then(|a| a.as_str()), Some("127.0.0.1"));
602        assert_eq!(
603            v.get("port").and_then(|p| p.as_u64()),
604            Some(DEFAULT_PORT as u64)
605        );
606    }
607
608    /// Why: the #1318 decoupling requires that an explicit argv parses to the
609    /// `Port` command and that the `port` handler runs without touching the
610    /// process's global argv. This exercises that parse → dispatch path.
611    /// What: parses `["trusty-console","port","--json"]` via `Cli::parse_from`
612    /// (the same call `run_from` makes) and runs `run_port` synchronously under
613    /// an isolated data dir; asserts the dispatch matches `Port` and the
614    /// handler returns Ok. Kept synchronous so the env-override mutex is never
615    /// held across an `await` (clippy::await_holding_lock).
616    /// Test: this test itself.
617    #[test]
618    fn test_run_from_port_json_outputs_envelope() {
619        let _guard = DATA_DIR_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
620        let tmp = std::env::temp_dir().join(format!(
621            "trusty-console-runfrom-test-{}-{}",
622            std::process::id(),
623            std::time::SystemTime::now()
624                .duration_since(std::time::UNIX_EPOCH)
625                .map(|d| d.as_nanos())
626                .unwrap_or(0)
627        ));
628        std::fs::create_dir_all(&tmp).expect("create temp data dir");
629        // SAFETY: guarded by DATA_DIR_ENV_LOCK to serialise env mutation.
630        unsafe {
631            std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, &tmp);
632        }
633        let argv = [
634            "trusty-console".to_owned(),
635            "port".to_owned(),
636            "--json".to_owned(),
637        ];
638        let cli = Cli::parse_from(argv);
639        let result = match cli.command {
640            Commands::Port(args) => {
641                assert!(args.json, "argv --json should parse to json=true");
642                run_port(args)
643            }
644            other => panic!("expected Port, got {other:?}"),
645        };
646        unsafe {
647            std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
648        }
649        assert!(result.is_ok(), "run_port(port --json) should succeed");
650    }
651}