Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@ 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.

## 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`.

Expand Down
6 changes: 6 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ enum ProbingCommands {
#[arg(long, help = "Override source IPv6 address (auto-detected per agent if not set)")]
src_ip: Option<String>,
},
#[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'")]
Expand Down Expand Up @@ -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,
}
}
Expand Down
81 changes: 79 additions & 2 deletions src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ pub enum OutputFormat {
Text,
#[value(name = "json")]
Json,
#[value(name = "csv")]
Csv,
}

thread_local! {
Expand All @@ -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::<Vec<_>>().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 {
Expand Down Expand Up @@ -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));
}
}
}

Expand Down Expand Up @@ -135,5 +178,39 @@ pub fn table(headers: &[&str], rows: &[Vec<String>]) -> 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");
}
}
28 changes: 16 additions & 12 deletions src/peering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <hours>");
}
Expand Down Expand Up @@ -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 <hours>");
}
Expand Down Expand Up @@ -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!(
Expand All @@ -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();
Expand Down Expand Up @@ -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<Vec<String>> = paths
.iter()
.take(shown)
Expand All @@ -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));
}

Expand Down
72 changes: 62 additions & 10 deletions src/probing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ pub async fn agents() -> anyhow::Result<()> {
let agents: Vec<Agent> = 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(());
}

Expand Down Expand Up @@ -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(());
}

Expand Down Expand Up @@ -264,6 +268,49 @@ async fn query_clickhouse(sql: &str) -> anyhow::Result<Vec<serde_json::Value>> {
.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<Measurement> = 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 <id> probes.csv");
}
return Ok(());
}

let rows: Vec<Vec<String>> = 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 <id>");

Ok(())
}

pub async fn measurement_status(id: &str) -> anyhow::Result<()> {
#[derive(serde::Deserialize)]
struct AgentStatus {
Expand All @@ -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<Vec<String>> = status.agents.iter().map(|a| {
Expand Down