diff --git a/CLAUDE.md b/CLAUDE.md index aa7d363..25f417d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ The CLI is organized as thin layers; command modules call a shared HTTP client a - **`api.rs`** — `ApiClient`, a small `reqwest` wrapper with `get`/`get_public`/`post`/`put`/`delete`. There are **two backends**: `ApiClient::new()` (PeerLab API) and `ApiClient::new_saimiris()` (probing API) — pick by which platform the command targets. Authed methods inject the bearer token and validate expiry first (deleting tokens and erroring if expired); `get_public` skips auth. - **`auth.rs`** — Auth0 **OAuth2 Device Authorization flow**: `start_device_flow` → `poll_for_token` (handles `authorization_pending`/`slow_down`) → tokens. `refresh_access_token` renews using the stored refresh token. Returns `(access_token, refresh_token, expires_at_unix_secs)`. - **`config.rs`** — token persistence to `tokens.json` in the OS config dir (`directories::ProjectDirs::from("dev", "nxthdr", "nxthdr")`). Expiry is enforced client-side from `expires_at`. -- **`output.rs`** — the single output abstraction. A thread-local `OutputFormat` (set once in `main`) makes every helper format-aware: `section`/`info`/`success`/`warn`/`hint` render only in text mode, while `kv` and `table` emit aligned text **or** structured JSON. Auto-sizes column widths in text mode. +- **`output.rs`** — the single output abstraction. A thread-local `OutputFormat` (set once in `main`) makes every helper format-aware across three formats (`text`/`json`/`csv`): `section`/`info`/`success`/`warn`/`hint` render only in text mode (gate any text-only decoration or truncation on `output::is_text()`), while `kv` and `table` emit aligned text, structured JSON, **or** RFC-4180 CSV (a `kv` block becomes a single-row CSV; `table` is header + rows). Auto-sizes column widths in text mode. - **`peering.rs`** — peering commands against the PeerLab API (`/api/user/info`, `/api/user/prefix`, …), plus `routes`/`lookup` which layer on `ris.rs` for BGP visibility. - **`probing.rs`** — probing commands against the Saimiris API, plus `results` which queries ClickHouse directly. - **`ris.rs`** — RIPEstat looking-glass client (15s timeout since it's an external public service). Aggregates RIS data into `Visibility` (peer/collector counts, origins, AS paths) and computes propagation % against the full-feed peer count. @@ -42,7 +42,7 @@ The CLI is organized as thin layers; command modules call a shared HTTP client a ## Conventions specific to this repo - **Request/response types are declared inline inside each command function**, not in a shared models module (e.g. a local `#[derive(Deserialize)] struct UserInfo` scoped to `asn()`). Follow this — only hoist a type when genuinely shared. Each function deserializes just the fields it needs. -- **Never `println!` user-facing data directly** — route everything through `output.rs` so `-o json` keeps working. The deliberate exceptions are output that is machine-data-by-design: `peering peerlab env` writes a dotenv file to stdout, and empty results print a literal `[]` guarded by `output::is_json()`. Every command must handle its empty/“nothing found” case under both formats. +- **Never `println!` user-facing data directly** — route everything through `output.rs` so `-o json`/`-o csv` keep working. The deliberate exception is output that is machine-data-by-design: `peering peerlab env` writes a dotenv file to stdout. Every command must handle its empty/“nothing found” case via `output::empty(headers)`, which prints `[]` (JSON) or a header-only row (CSV) and returns `true`, or returns `false` in text mode so the caller can emit a friendly note + hint. - **Errors use `anyhow`** end to end (`.context(...)`, `anyhow::bail!`, `anyhow::ensure!`); they bubble up to `main`. Attach user-actionable context (e.g. suggest the `nxthdr` command to run next via `output::hint`). - **`tracing::debug!`** is the channel for diagnostic detail (URLs, derived values); surface it with `-v`. diff --git a/src/main.rs b/src/main.rs index 5cd29d9..46caf3f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,6 +71,11 @@ enum ProbingCommands { #[arg(long, help = "Override source IPv6 address (auto-detected per agent if not set)")] src_ip: Option, }, + #[command(about = "List your recent measurements")] + Measurements { + #[arg(long, default_value_t = 20, help = "Maximum number of measurements to list (1-100)")] + limit: u32, + }, #[command(about = "Get status of a measurement by ID")] MeasurementStatus { #[arg(help = "Measurement ID returned by 'send'")] @@ -170,6 +175,7 @@ async fn handle_probing(command: ProbingCommands) -> anyhow::Result<()> { ProbingCommands::Agents => probing::agents().await, ProbingCommands::Send { file, agent, src_ip } => probing::send(file, agent, src_ip).await, ProbingCommands::Results { src_ip, since, until } => probing::results(src_ip, since, until).await, + ProbingCommands::Measurements { limit } => probing::measurements(limit).await, ProbingCommands::MeasurementStatus { id } => probing::measurement_status(&id).await, } } diff --git a/src/output.rs b/src/output.rs index 3852ef8..db872a2 100644 --- a/src/output.rs +++ b/src/output.rs @@ -6,6 +6,8 @@ pub enum OutputFormat { Text, #[value(name = "json")] Json, + #[value(name = "csv")] + Csv, } thread_local! { @@ -16,14 +18,47 @@ pub fn set_format(fmt: OutputFormat) { FORMAT.with(|f| f.set(fmt)); } -pub fn is_json() -> bool { - FORMAT.with(|f| f.get()) == OutputFormat::Json +/// True only in text mode — use to gate human-facing decoration (notes, hints, +/// truncation) that must not pollute machine-readable JSON/CSV output. +pub fn is_text() -> bool { + FORMAT.with(|f| f.get()) == OutputFormat::Text } fn fmt() -> OutputFormat { FORMAT.with(|f| f.get()) } +/// Escape a single CSV field per RFC 4180: quote it when it contains a comma, +/// quote, or newline, doubling any embedded quotes. +fn csv_field(s: &str) -> String { + if s.contains([',', '"', '\n', '\r']) { + format!("\"{}\"", s.replace('"', "\"\"")) + } else { + s.to_string() + } +} + +fn csv_line(cells: &[&str]) -> String { + cells.iter().map(|c| csv_field(c)).collect::>().join(",") +} + +/// Render an empty result set for the active format. In text mode it does +/// nothing and returns `false` so the caller can print a friendly note; in +/// JSON it prints `[]` and in CSV the header row, returning `true`. +pub fn empty(headers: &[&str]) -> bool { + match fmt() { + OutputFormat::Text => false, + OutputFormat::Json => { + println!("[]"); + true + } + OutputFormat::Csv => { + println!("{}", csv_line(headers)); + true + } + } +} + /// Print a section heading with a separator (text mode only). pub fn section(title: &str) { if fmt() == OutputFormat::Text { @@ -83,6 +118,14 @@ pub fn kv(pairs: &[(&str, &str)]) { serde_json::to_string_pretty(&serde_json::Value::Object(obj)).unwrap() ); } + OutputFormat::Csv => { + // A key/value block becomes a single-row CSV: keys as the header, + // values as the one data row. + let keys: Vec<&str> = pairs.iter().map(|(k, _)| *k).collect(); + let vals: Vec<&str> = pairs.iter().map(|(_, v)| *v).collect(); + println!("{}", csv_line(&keys)); + println!("{}", csv_line(&vals)); + } } } @@ -135,5 +178,39 @@ pub fn table(headers: &[&str], rows: &[Vec]) -> usize { ); 0 } + OutputFormat::Csv => { + println!("{}", csv_line(headers)); + for row in rows { + let cells: Vec<&str> = row.iter().map(|s| s.as_str()).collect(); + println!("{}", csv_line(&cells)); + } + 0 + } + } +} + +#[cfg(test)] +mod tests { + use super::{csv_field, csv_line}; + + #[test] + fn csv_field_leaves_plain_values_unquoted() { + assert_eq!(csv_field("vltcdg01"), "vltcdg01"); + assert_eq!(csv_field("2001:db8::/48"), "2001:db8::/48"); + } + + #[test] + fn csv_field_quotes_and_escapes_special_chars() { + // Comma forces quoting. + assert_eq!(csv_field("a,b"), "\"a,b\""); + // Embedded quotes are doubled and the field is wrapped. + assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\""); + // Newlines force quoting. + assert_eq!(csv_field("line1\nline2"), "\"line1\nline2\""); + } + + #[test] + fn csv_line_joins_escaped_fields() { + assert_eq!(csv_line(&["a", "b,c", "d"]), "a,\"b,c\",d"); } } diff --git a/src/peering.rs b/src/peering.rs index 4a87085..04297bd 100644 --- a/src/peering.rs +++ b/src/peering.rs @@ -38,7 +38,7 @@ pub async fn prefix_list() -> anyhow::Result<()> { let user_info: UserInfo = api::ApiClient::new().get("/api/user/info").await?; if user_info.active_leases.is_empty() { - if output::is_json() { println!("[]"); } else { + if !output::empty(&["prefix", "expires", "rpki"]) { output::info("no active prefix leases"); output::hint("nxthdr peering prefix request "); } @@ -175,9 +175,15 @@ pub async fn routes() -> anyhow::Result<()> { let user_info: UserInfo = api::ApiClient::new().get("/api/user/info").await?; if user_info.active_leases.is_empty() { - if output::is_json() { - println!("[]"); - } else { + if !output::empty(&[ + "prefix", + "visible", + "propagation", + "collectors", + "peers", + "origin", + "shortest path", + ]) { output::info("no active prefix leases — nothing to announce"); output::hint("nxthdr peering prefix request "); } @@ -215,7 +221,7 @@ pub async fn routes() -> anyhow::Result<()> { &rows, ); - if !output::is_json() { + if output::is_text() { let suffix = as_of.map(|t| format!(" (as of {t})")).unwrap_or_default(); // AS215011 is PeerLab's export ASN; user (private) ASNs are stripped on export. output::info(&format!( @@ -233,15 +239,13 @@ pub async fn lookup(prefix: &str) -> anyhow::Result<()> { let vis = ris::looking_glass(prefix).await?; if !vis.is_visible() { - if output::is_json() { - println!("[]"); - } else { + if !output::empty(&["origin", "as_path", "peers", "collectors"]) { output::info(&format!("{prefix} is not visible in any RIPE RIS collector")); } return Ok(()); } - if !output::is_json() { + if output::is_text() { let origins = vis.origins(); let origin_str = if origins.is_empty() { "-".to_string() } else { origins.join(", ") }; let collectors = vis.collector_count().to_string(); @@ -270,8 +274,8 @@ pub async fn lookup(prefix: &str) -> anyhow::Result<()> { } let paths = vis.paths(); - // JSON is machine-consumed, so emit every path; the terminal table is capped. - let shown = if output::is_json() { paths.len() } else { paths.len().min(20) }; + // Machine formats (JSON/CSV) emit every path; the terminal table is capped. + let shown = if output::is_text() { paths.len().min(20) } else { paths.len() }; let rows: Vec> = paths .iter() .take(shown) @@ -287,7 +291,7 @@ pub async fn lookup(prefix: &str) -> anyhow::Result<()> { output::table(&["origin", "as_path", "peers", "collectors"], &rows); - if !output::is_json() && paths.len() > shown { + if output::is_text() && paths.len() > shown { output::info(&format!("\n… and {} more distinct paths", paths.len() - shown)); } diff --git a/src/probing.rs b/src/probing.rs index 42d5b26..1c00e1a 100644 --- a/src/probing.rs +++ b/src/probing.rs @@ -49,7 +49,9 @@ pub async fn agents() -> anyhow::Result<()> { let agents: Vec = api::ApiClient::new_saimiris().get_public("/api/agents").await?; if agents.is_empty() { - if output::is_json() { println!("[]"); } else { output::info("no agents available"); } + if !output::empty(&["id", "status", "prefixes"]) { + output::info("no agents available"); + } return Ok(()); } @@ -226,7 +228,9 @@ pub async fn results( let rows = query_clickhouse(&sql).await?; if rows.is_empty() { - if output::is_json() { println!("[]"); } else { output::info(&format!("no replies found for {}", src_ips.join(", "))); } + if !output::empty(&["agent", "src", "dst", "ttl", "reply", "rtt"]) { + output::info(&format!("no replies found for {}", src_ips.join(", "))); + } return Ok(()); } @@ -264,6 +268,49 @@ async fn query_clickhouse(sql: &str) -> anyhow::Result> { .collect() } +pub async fn measurements(limit: u32) -> anyhow::Result<()> { + anyhow::ensure!((1..=100).contains(&limit), "--limit must be between 1 and 100"); + + #[derive(Deserialize)] + struct Measurement { + measurement_id: String, + total_agents: i64, + completed_agents: i64, + total_expected_probes: i64, + total_sent_probes: i64, + measurement_complete: bool, + started_at: String, + } + + let measurements: Vec = api::ApiClient::new_saimiris() + .get(&format!("/api/measurements?limit={limit}")) + .await?; + + if measurements.is_empty() { + if !output::empty(&["id", "started", "agents", "probes", "status"]) { + output::info("no measurements found"); + output::hint("nxthdr probing send --agent probes.csv"); + } + return Ok(()); + } + + let rows: Vec> = measurements.iter().map(|m| { + let status = if m.measurement_complete { "complete" } else { "in progress" }; + vec![ + m.measurement_id.clone(), + m.started_at.clone(), + format!("{}/{}", m.completed_agents, m.total_agents), + format!("{}/{}", m.total_sent_probes, m.total_expected_probes), + status.to_string(), + ] + }).collect(); + + output::table(&["id", "started", "agents", "probes", "status"], &rows); + output::hint("nxthdr probing measurement-status "); + + Ok(()) +} + pub async fn measurement_status(id: &str) -> anyhow::Result<()> { #[derive(serde::Deserialize)] struct AgentStatus { @@ -288,14 +335,19 @@ pub async fn measurement_status(id: &str) -> anyhow::Result<()> { .get(&format!("/api/measurement/{id}/status")) .await?; - let overall = if status.measurement_complete { "complete" } else { "in progress" }; - output::section("measurement"); - output::kv(&[ - ("id", &status.measurement_id), - ("status", overall), - ("agents", &format!("{}/{} complete", status.completed_agents, status.total_agents)), - ("probes", &format!("{}/{} sent", status.total_sent_probes, status.total_expected_probes)), - ]); + // Text mode shows the rich summary block; machine formats (json/csv) emit + // only the per-agent table below, so the output stays a single valid block + // (one CSV header / one JSON value) instead of a summary followed by a table. + if output::is_text() { + let overall = if status.measurement_complete { "complete" } else { "in progress" }; + output::section("measurement"); + output::kv(&[ + ("id", &status.measurement_id), + ("status", overall), + ("agents", &format!("{}/{} complete", status.completed_agents, status.total_agents)), + ("probes", &format!("{}/{} sent", status.total_sent_probes, status.total_expected_probes)), + ]); + } if !status.agents.is_empty() { let rows: Vec> = status.agents.iter().map(|a| {