From 945f2ff77f11e0381d974548a7f0f66bd158aa8f Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:57 -0700 Subject: [PATCH] feat(desktop): add switchyard-desktop --print for recorded costs and savings Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/main.rs | 76 +++++-- crates/switchyard-desktop/src/pricing.rs | 97 ++++++++ crates/switchyard-desktop/src/rollup.rs | 17 ++ crates/switchyard-desktop/src/summary.rs | 270 +++++++++++++++++++++++ crates/switchyard-desktop/tests/print.rs | 196 ++++++++++++++++ 5 files changed, 641 insertions(+), 15 deletions(-) create mode 100644 crates/switchyard-desktop/src/pricing.rs create mode 100644 crates/switchyard-desktop/src/summary.rs create mode 100644 crates/switchyard-desktop/tests/print.rs diff --git a/crates/switchyard-desktop/src/main.rs b/crates/switchyard-desktop/src/main.rs index 56542da54..ea1bd892d 100644 --- a/crates/switchyard-desktop/src/main.rs +++ b/crates/switchyard-desktop/src/main.rs @@ -1,13 +1,18 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! The desktop window shows local server health and recorded usage. +//! The desktop app reports a local Switchyard server's recorded usage. +//! +//! The printed summary shows recorded traffic and compares list-price estimates with the +//! configured baseline model. #![cfg_attr(windows, windows_subsystem = "windows")] -use switchyard_desktop::config; +use switchyard_desktop::{config, health, rollup}; #[cfg(any(target_os = "macos", windows))] mod gui; +mod pricing; +mod summary; use std::path::PathBuf; use std::process::ExitCode; @@ -15,12 +20,12 @@ use std::process::ExitCode; use config::Config; const USAGE: &str = "\ -Usage: switchyard-desktop [--validate-toml] [FILE] +Usage: switchyard-desktop [--print | --validate-toml] [FILE] -This app opens a window with server status and recorded usage. -The desktop window supports macOS and Windows. +This app opens the desktop window. Use --print for a usage summary. FILE The app reads settings from FILE. + --print Print the current summary and exit. --validate-toml Parse FILE as TOML and exit. Without FILE, the app uses bundled installation metadata to find desktop.toml. @@ -30,7 +35,11 @@ when SY_HOME is unset. Missing settings use the directory and port from adjacent install.toml, or the settings file's directory and port 4123 when that metadata is absent. The default server URL includes the port, such as http://127.0.0.1:4123. -A configured HTTP URL without a port uses port 80."; +A configured HTTP URL without a port uses port 80. + +Savings compare spending on all calls with the cost of the answers at +baseline_model's bundled rates or the table selected by pricing_file. +Recorded spending remains visible when savings are unknown."; fn main() -> ExitCode { // A desktop launch may have no parent console, so attachment failure does not stop the app. @@ -42,12 +51,14 @@ fn main() -> ExitCode { }; let mut settings = None; let mut validate_toml = false; + let mut print_only = false; for arg in std::env::args().skip(1) { match arg.as_str() { "-h" | "--help" => { println!("{USAGE}"); return ExitCode::SUCCESS; } + "--print" => print_only = true, "--validate-toml" => validate_toml = true, _ if arg.starts_with('-') => { eprintln!("switchyard-desktop: unknown option {arg}\n\n{USAGE}"); @@ -76,18 +87,53 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - #[cfg(any(target_os = "macos", windows))] - match gui::run(settings) { - Ok(()) => ExitCode::SUCCESS, + if !print_only { + #[cfg(any(target_os = "macos", windows))] + return match gui::run(settings) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("switchyard-desktop: {error}"); + ExitCode::FAILURE + } + }; + #[cfg(not(any(target_os = "macos", windows)))] + { + eprintln!( + "The desktop window supports macOS and Windows. Use --print for a usage summary." + ); + return ExitCode::FAILURE; + } + } + + let config = match Config::load(&settings) { + Ok(config) => config, Err(error) => { eprintln!("switchyard-desktop: {error}"); - ExitCode::FAILURE + return ExitCode::FAILURE; } - } - #[cfg(not(any(target_os = "macos", windows)))] + }; + let usage = match rollup::Reader::default() + .read(&config.routing_log, chrono::Local::now().date_naive()) { - let _ = settings; - eprintln!("The desktop window supports macOS and Windows."); - ExitCode::FAILURE + Ok(usage) => usage, + Err(error) => { + eprintln!( + "Usage unavailable. Could not read {}: {error}", + config.routing_log.display() + ); + return ExitCode::FAILURE; + } + }; + let rates = match pricing::load(config.prices_file.as_deref()) { + Ok(rates) => rates, + Err(error) => { + eprintln!("switchyard-desktop: {error}"); + return ExitCode::FAILURE; + } + }; + let status = health::Probe::default().probe(&config.server_url); + for row in summary::build(status, &usage, &config, &rates) { + println!("{row}"); } + ExitCode::SUCCESS } diff --git a/crates/switchyard-desktop/src/pricing.rs b/crates/switchyard-desktop/src/pricing.rs new file mode 100644 index 000000000..6a56d70dc --- /dev/null +++ b/crates/switchyard-desktop/src/pricing.rs @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module compares the cost that the server recorded with the cost of the same answers on +//! the baseline model. The server prices each call when it finishes. The app uses bundled +//! rates or a configured override only to price the baseline. + +use std::path::Path; + +use serde::Serialize; +use switchyard_costs::Pricing; + +use crate::rollup::Totals; + +/// This function loads bundled rates when no override is selected. +/// An explicit file replaces the entire table; this function returns an error if it cannot load it. +pub fn load(path: Option<&Path>) -> Result { + match path { + Some(path) => Pricing::load(path), + None => Pricing::bundled(), + } +} + +/// Estimate stores one period's recorded cost and the baseline model's cost for its answers. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub struct Estimate { + /// This field stores the recorded cost of every call, including routing calls. It is + /// `None` when any call has no recorded cost. + pub actual: Option, + /// This field adds up the calls that have a recorded cost. + pub known: f64, + /// This flag distinguishes a recorded zero cost from missing costs. + pub has_known_cost: bool, + /// This field counts calls that the server recorded without a cost. + pub unknown_calls: u64, + /// This field prices the answer calls at the baseline model's rates. It is `None` when + /// the selected price table has no rates for the baseline model. + pub baseline: Option, + /// This field counts answer calls recorded without token counts. The baseline cannot + /// price them, so they hide the savings. + pub uncounted_answers: u64, +} + +impl Estimate { + /// This function totals recorded costs and prices only answer tokens for the baseline. + pub fn new(totals: &Totals, rates: &Pricing, baseline_model: &str) -> Self { + let cost = totals.cost(); + // Routing calls would not exist without Switchyard, so the baseline prices only answers. + let baseline = rates + .contains(baseline_model) + .then(|| { + totals.routed.values().try_fold(0.0, |sum, tokens| { + Some(sum + rates.estimate(baseline_model, &tokens.usage())?.usd) + }) + }) + .flatten(); + Self { + actual: cost.total_usd, + known: cost.known_usd, + has_known_cost: cost.estimated_calls > 0 || cost.provider_reported_calls > 0, + unknown_calls: cost.unknown_calls, + baseline, + uncounted_answers: totals.routed.values().map(|tokens| tokens.uncounted).sum(), + } + } + + /// This function returns the savings when every call has a cost, every answer has token + /// counts, and the baseline has rates. + pub fn savings(&self) -> Option { + if self.uncounted_answers > 0 { + return None; + } + Some(Savings { + actual: self.actual?, + baseline: self.baseline?, + }) + } +} + +/// Savings stores a period's recorded cost and the baseline cost of the same answers. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Savings { + pub actual: f64, + pub baseline: f64, +} + +impl Savings { + /// This function returns estimated dollars saved; routing overhead can make the value negative. + pub fn saved(&self) -> f64 { + self.baseline - self.actual + } + + /// This function returns the percentage saved, or `None` when the baseline cost is zero. + pub fn percent(&self) -> Option { + (self.baseline > 0.0).then(|| self.saved() / self.baseline * 100.0) + } +} diff --git a/crates/switchyard-desktop/src/rollup.rs b/crates/switchyard-desktop/src/rollup.rs index 73175ca8f..2a87e0067 100644 --- a/crates/switchyard-desktop/src/rollup.rs +++ b/crates/switchyard-desktop/src/rollup.rs @@ -52,6 +52,23 @@ impl ModelTokens { self.cost.merge(&other.cost); self.uncounted += other.uncounted; } + + /// This function returns the token counts as usage that a price table can price. + pub fn usage(&self) -> switchyard_protocol::Usage { + let mut cache = switchyard_protocol::Usage::cache_details( + Some(self.cached_input), + Some(self.cache_write), + ); + if let Some(cache) = cache.as_mut() { + cache.cache_creation_1h_input_tokens = Some(self.cache_write_1h); + } + switchyard_protocol::Usage { + input_tokens: Some(self.input), + output_tokens: Some(self.output), + cache, + ..Default::default() + } + } } /// Totals separates caller-facing calls from classifier calls for a period. diff --git a/crates/switchyard-desktop/src/summary.rs b/crates/switchyard-desktop/src/summary.rs new file mode 100644 index 000000000..2e143bdcf --- /dev/null +++ b/crates/switchyard-desktop/src/summary.rs @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module turns totals and recorded costs into usage summary rows. + +use switchyard_costs::Pricing; + +use crate::pricing::{Estimate, Savings}; +use crate::rollup::{Totals, Usage}; +use switchyard_desktop::config::Config; +use switchyard_desktop::health::ServerStatus; + +const MAX_MODEL_ROWS: usize = 5; + +/// This function builds the menu body for the current day and trailing week. +pub fn build(status: ServerStatus, usage: &Usage, config: &Config, rates: &Pricing) -> Vec { + let state = match status { + ServerStatus::Running => "running", + ServerStatus::Stopped => "not responding", + }; + let mut rows = vec![ + format!("Server: {state} · {}", config.server_url), + String::new(), + ]; + + if usage.week.is_empty() { + rows.push(if usage.all.is_empty() { + "No requests recorded yet".into() + } else { + "No requests recorded in the last 7 days".into() + }); + return rows; + } + + rows.extend(period("Today", &usage.today, config, rates)); + rows.push(String::new()); + rows.extend(period("This week", &usage.week, config, rates)); + + let models = model_shares(&usage.week); + if !models.is_empty() { + rows.push(String::new()); + rows.push("This week by model".into()); + rows.extend(models); + } + + let unknown = usage.week.cost().unknown_calls; + let uncounted: u64 = usage + .week + .routed + .values() + .map(|tokens| tokens.uncounted) + .sum(); + if !rates.contains(&config.baseline_model) { + rows.push(String::new()); + rows.push(match config.prices_file.as_ref() { + Some(path) => format!("Add {} to {} to see savings", config.baseline_model, path.display()), + None => format!("Bundled prices have no rates for {}. Set pricing_file to a custom table to see savings.", config.baseline_model), + }); + } else if unknown > 0 { + rows.push(String::new()); + rows.push(format!( + "Savings hidden for this week: {unknown} {} no recorded cost", + if unknown == 1 { + "call has" + } else { + "calls have" + } + )); + } else if uncounted > 0 { + rows.push(String::new()); + let answers = if uncounted == 1 { + "answer has" + } else { + "answers have" + }; + rows.push(format!( + "Savings hidden for this week: {uncounted} {answers} no token counts" + )); + } + rows +} + +/// Each period shows its complete cost or labels its known subtotal as incomplete. +fn period(name: &str, totals: &Totals, config: &Config, rates: &Pricing) -> Vec { + let requests = totals.requests(); + let mut rows = vec![format!( + "{name} — {requests} request{} · {} tokens", + if requests == 1 { "" } else { "s" }, + tokens(totals.tokens()) + )]; + rows.extend(cost_rows(&Estimate::new( + totals, + rates, + &config.baseline_model, + ))); + rows +} + +/// This function formats a period's recorded spending and, when it is known, the savings. +pub fn cost_rows(estimate: &Estimate) -> Vec { + let mut rows = vec![match estimate.actual { + Some(actual) => format!(" Spent {}", money(actual)), + None if estimate.has_known_cost => format!( + " Known spending {} · total unknown ({} {} no recorded cost)", + money(estimate.known), + estimate.unknown_calls, + if estimate.unknown_calls == 1 { + "call has" + } else { + "calls have" + }, + ), + None => " Spending unknown".into(), + }]; + if let Some(savings) = estimate.savings() { + rows.push(format!(" Saved {}", saved(&savings))); + } + rows +} + +fn saved(savings: &Savings) -> String { + let amount = money(savings.saved()); + match savings.percent() { + Some(percent) => format!("{amount} ({percent:.0}% of {})", money(savings.baseline)), + None => amount, + } +} + +/// The summary ranks up to five answer models by token share and excludes classifier calls. +fn model_shares(week: &Totals) -> Vec { + let total: u64 = week.routed.values().map(|tokens| tokens.total()).sum(); + if total == 0 { + return Vec::new(); + } + let mut models: Vec<_> = week + .routed + .iter() + .map(|(model, tokens)| (model, tokens.total())) + .collect(); + // Models appear by decreasing share; the model ID breaks ties for a stable order. + models.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(right.0))); + models + .into_iter() + .take(MAX_MODEL_ROWS) + .map(|(model, count)| format!(" {model} — {:.0}%", count as f64 / total as f64 * 100.0)) + .collect() +} + +/// This function formats a token count compactly, since exact totals are not useful here. +pub(crate) fn tokens(value: u64) -> String { + match value { + 0..=9_999 => value.to_string(), + 10_000..=999_999 => format!("{:.0}K", value as f64 / 1_000.0), + 1_000_000..=999_999_999 => format!("{:.1}M", value as f64 / 1_000_000.0), + _ => format!("{:.1}B", value as f64 / 1_000_000_000.0), + } +} + +pub(crate) fn money(value: f64) -> String { + if value < 0.0 { + format!("-${:.2}", -value) + } else { + format!("${value:.2}") + } +} + +#[cfg(test)] +mod tests { + use super::build; + use crate::rollup::{ModelTokens, Totals, Usage}; + use switchyard_costs::{CostSource, Pricing, RecordedCost}; + use switchyard_desktop::config::Config; + use switchyard_desktop::health::ServerStatus; + + /// The fixture uses both cost sources so each can contribute a partial subtotal. + fn usage_with_costs(answer_usd: Option, classifier_usd: Option) -> Usage { + let mut totals = Totals::default(); + for (bucket, model, usd, source) in [ + ( + &mut totals.routed, + "answer", + answer_usd, + CostSource::Estimated, + ), + ( + &mut totals.classifier, + "classifier", + classifier_usd, + CostSource::ProviderReported, + ), + ] { + let mut tokens = ModelTokens { + requests: 1, + input: 100, + output: 10, + ..ModelTokens::default() + }; + tokens.cost.add(usd.map(|usd| RecordedCost { usd, source })); + bucket.insert(model.into(), tokens); + } + Usage { + today: totals.clone(), + week: totals, + ..Usage::default() + } + } + + /// The test checks complete, partial, missing, and zero costs in both summary periods. + #[test] + fn summary_shows_known_subtotals_and_missing_cost_warnings() { + let directory = tempfile::tempdir().expect("settings directory"); + let config = + Config::defaults_for(&directory.path().join("desktop.toml")).expect("default settings"); + let rates = Pricing::bundled().expect("bundled prices"); + for (answer_usd, classifier_usd, expected, unknown) in [ + ( + Some(1.25), + None, + " Known spending $1.25 · total unknown (1 call has no recorded cost)", + 1, + ), + ( + None, + Some(1.25), + " Known spending $1.25 · total unknown (1 call has no recorded cost)", + 1, + ), + (Some(1.25), Some(0.25), " Spent $1.50", 0), + (None, None, " Spending unknown", 2), + ( + Some(0.0), + None, + " Known spending $0.00 · total unknown (1 call has no recorded cost)", + 1, + ), + ] { + let usage = usage_with_costs(answer_usd, classifier_usd); + let rows = build(ServerStatus::Running, &usage, &config, &rates); + let spending: Vec<_> = rows + .iter() + .filter(|row| { + row.starts_with(" Spent ") + || row.starts_with(" Known spending ") + || row.starts_with(" Spending ") + }) + .map(String::as_str) + .collect(); + assert_eq!( + spending, + [expected, expected], + "each period should show the expected spending row" + ); + let warnings: Vec<_> = rows + .iter() + .filter(|row| row.starts_with("Savings hidden for this week:")) + .map(String::as_str) + .collect(); + let expected_warnings = match unknown { + 0 => vec![], + 1 => vec!["Savings hidden for this week: 1 call has no recorded cost"], + 2 => vec!["Savings hidden for this week: 2 calls have no recorded cost"], + _ => unreachable!(), + }; + assert_eq!( + warnings, expected_warnings, + "the weekly summary should report the expected missing-cost warning" + ); + } + } +} diff --git a/crates/switchyard-desktop/tests/print.rs b/crates/switchyard-desktop/tests/print.rs new file mode 100644 index 000000000..0eef5a76d --- /dev/null +++ b/crates/switchyard-desktop/tests/print.rs @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#[test] +fn print_uses_bundled_prices_and_requires_valid_explicit_overrides() +-> Result<(), Box> { + let dir = tempfile::tempdir().expect("fixture"); + let settings = dir.path().join("desktop.toml"); + let log = dir.path().join("routing.jsonl"); + // Both token counts are positive so the fixture qualifies for a baseline estimate. + let record = serde_json::json!({ + "ts": chrono::Local::now().to_rfc3339(), + "model": "gpt-6-sol", "prompt_tokens": 1_000_000, "completion_tokens": 100_000, + "cost": {"usd": 0.1, "source": "provider_reported"} + }); + std::fs::write(&log, format!("{record}\n")).expect("log"); + let base = format!("server_url='http://127.0.0.1:1'\nrouting_log={log:?}\n"); + std::fs::write(&settings, &base).expect("settings"); + let output = print(&settings); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("Saved $2.90 (97% of $3.00)")); + assert!(!dir.path().join("custom-prices.toml").exists()); + // A different bundled baseline changes the estimate; the saved $0.10 charge stays the same. + std::fs::write( + &settings, + format!("{base}baseline_model='claude-opus-5-5'\n"), + )?; + let output = print(&settings); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("Saved $5.90 (98% of $6.00)")); + for (name, text) in [ + ( + "custom-prices.json", + r#"{"gpt-6-sol":{"input":9,"output":0,"cache_read":0,"cache_write":0}}"#, + ), + ( + "custom-prices.toml", + "[\"gpt-6-sol\"]\ninput=9\noutput=0\ncache_read=0\ncache_write=0\n", + ), + ] { + let prices = dir.path().join(name); + std::fs::write(&settings, format!("{base}pricing_file={prices:?}\n")) + .expect("override settings"); + for invalid in [None, Some("not json")] { + if let Some(text) = invalid { + std::fs::write(&prices, text).expect("invalid file"); + } + let output = print(&settings); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("pricing file")); + } + std::fs::write(&prices, text).expect("override"); + let output = print(&settings); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("Saved $8.90 (99% of $9.00)")); + // The omitted model distinguishes table replacement from merging bundled rates. + std::fs::write( + &settings, + format!("{base}pricing_file={prices:?}\nbaseline_model='gpt-6-luna'\n"), + ) + .expect("missing baseline"); + let output = print(&settings); + assert!(output.status.success()); + assert!(!String::from_utf8_lossy(&output.stdout).contains("Saved")); + } + assert_eq!( + std::fs::read_to_string(&log).expect("unchanged log"), + format!("{record}\n") + ); + Ok(()) +} + +fn print(settings: &std::path::Path) -> std::process::Output { + std::process::Command::new(env!("CARGO_BIN_EXE_switchyard-desktop")) + .arg("--print") + .arg(settings) + .output() + .expect("print") +} + +fn record() -> serde_json::Value { + serde_json::json!({ + "ts": chrono::Utc::now().to_rfc3339(), + "model": "actual", + "prompt_tokens": 1_000_000, + "completion_tokens": 1_000_000, + "cost": {"usd": 1, "source": "provider_reported"} + }) +} + +#[test] +fn recorded_spending_stays_visible_when_savings_are_unavailable() { + let dir = tempfile::tempdir().expect("fixture"); + let settings = dir.path().join("desktop.toml"); + let log = dir.path().join("routing.jsonl"); + let prices = dir.path().join("prices.json"); + std::fs::write( + &settings, + format!("server_url='http://127.0.0.1:0'\nbaseline_model='baseline'\nrouting_log={:?}\npricing_file={:?}\n", log, prices), + ) + .expect("settings"); + let valid = record(); + let mut unknown = record(); + unknown.as_object_mut().expect("record").remove("cost"); + let mut uncounted = record(); + uncounted["prompt_tokens"] = 0.into(); + for (entries, rates, spending) in [ + ( + vec![valid.clone(), unknown], + true, + "Known spending $1.00 · total unknown (1 call has no recorded cost)", + ), + (vec![valid], false, "Spent $1.00"), + (vec![uncounted], true, "Spent $1.00"), + ] { + let text = entries + .iter() + .map(|entry| format!("{entry}\n")) + .collect::(); + std::fs::write(&log, text).expect("log"); + std::fs::write( + &prices, + if rates { + r#"{"baseline":{"input":10,"output":10,"cache_read":0,"cache_write":0}}"# + } else { + "{}" + }, + ) + .expect("prices"); + let output = print(&settings); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 output"); + assert!(stdout.contains(spending), "{stdout}"); + assert!(!stdout.contains("Saved "), "{stdout}"); + } +} + +#[test] +fn invalid_complete_records_fail_before_health_checks_or_summary_output() { + let dir = tempfile::tempdir().expect("fixture"); + let settings = dir.path().join("desktop.toml"); + let log = dir.path().join("routing.jsonl"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener"); + listener.set_nonblocking(true).expect("nonblocking"); + let settings_text = format!( + "server_url='http://{}'\nrouting_log={:?}\n", + listener.local_addr().expect("address"), + log + ); + std::fs::write(&settings, &settings_text).expect("settings"); + let valid = record(); + let mut cost = valid.clone(); + cost["cost"]["usd"] = "damaged".into(); + let mut timestamp = valid.clone(); + timestamp["ts"] = "bad".into(); + let mut model = valid.clone(); + model["model"] = " ".into(); + let mut tokens = valid.clone(); + tokens["prompt_tokens"] = (-1).into(); + let mut id = valid.clone(); + id["session_id"] = 7.into(); + for text in [ + format!("{valid}\nnot-json\n"), + format!("{valid}\n{cost}\n"), + format!("{valid}\n{timestamp}\n"), + format!("{valid}\n{model}\n"), + format!("{valid}\n{tokens}\n"), + format!("{valid}\n{id}\n"), + ] { + std::fs::write(&log, &text).expect("log"); + let output = print(&settings); + assert!(!output.status.success(), "accepted {text}"); + assert!(output.stdout.is_empty(), "printed an incomplete summary"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Usage unavailable"), "{stderr}"); + assert!( + stderr.contains("invalid routing record at byte"), + "{stderr}" + ); + assert_eq!( + listener.accept().expect_err("no health request").kind(), + std::io::ErrorKind::WouldBlock + ); + assert_eq!(std::fs::read_to_string(&log).expect("unchanged log"), text); + assert_eq!( + std::fs::read_to_string(&settings).expect("unchanged settings"), + settings_text + ); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 2); + } +}