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