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