Skip to content
Open
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
18 changes: 9 additions & 9 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,15 @@ async fn run_cli(cmd: Option<Commands>, mode: &OutputMode) -> Result<serde_json:
})
})
.collect();
serde_json::json!({ "providers": providers })
// List subcommands return a bare array as `data` so `--fields`
// selects columns and `--format jsonl` streams one record per line.
serde_json::Value::Array(providers)
} else if args.tiers {
let slug = args.provider.as_deref().unwrap_or("cloudflare");
let provider = registry::get_provider(slug)
.ok_or_else(|| AppError::usage_error(format!("Unknown provider: {slug}")))?;
let tiers: Vec<_> = provider.tiers.iter().map(|t| t.to_string()).collect();
serde_json::json!({ "provider": slug, "tiers": tiers })
serde_json::json!(tiers)
} else if args.protocols {
let slug = args.provider.as_deref().unwrap_or("cloudflare");
let provider = registry::get_provider(slug)
Expand All @@ -161,16 +163,14 @@ async fn run_cli(cmd: Option<Commands>, mode: &OutputMode) -> Result<serde_json:
.iter()
.map(|p| p.to_string())
.collect();
serde_json::json!({ "provider": slug, "tier": tier.map(|t| t.to_string()), "protocols": protocols })
serde_json::json!(protocols)
} else if args.vpn {
let warp = vpn::warp::WarpProvider;
let adguard = vpn::adguard::AdGuardVpnProvider;
serde_json::json!({
"vpn_clients": [
{ "name": "warp", "available": warp.is_available() },
{ "name": "adguard", "available": adguard.is_available() }
]
})
serde_json::json!([
{ "name": "warp", "available": warp.is_available() },
{ "name": "adguard", "available": adguard.is_available() }
])
} else {
serde_json::json!({
"providers": registry::list_providers().len(),
Expand Down
219 changes: 219 additions & 0 deletions src/output/csv.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Rust guideline compliant 2026-05-18

//! Minimal RFC 4180-style CSV serializer for [`serde_json::Value`] payloads.
//!
//! CSV is a flat, tabular format, so Flux renders only the `data` payload here —
//! the `{ metadata, … }` envelope is JSON/YAML-specific and has no tabular
//! shape. Nested values within a cell are encoded as compact JSON to stay
//! lossless and unambiguous. Fields are LF-terminated (rather than RFC 4180's
//! CRLF) to match the rest of the tool's POSIX-friendly output.

use serde_json::{Map, Value};

/// Render a JSON value as CSV.
///
/// * An array of objects becomes a table whose header is the union of all keys.
/// * An array of scalars becomes a single `value` column.
/// * A single-key object wrapping an array (the shape every `list` command
/// returns, e.g. `{"providers": [..]}`) is unwrapped into that table.
/// * Any other object becomes a one-row table (keys as header).
/// * A bare scalar becomes a single cell.
/// * An empty array yields the empty string.
pub fn to_csv(value: &Value) -> String {
match value {
Value::Array(items) => array_to_csv(items),
Value::Object(map) => {
if let Some(items) = single_array_value(map) {
return array_to_csv(items);
}
let headers: Vec<String> = map.keys().cloned().collect();
let row: Vec<String> = map.values().map(cell).collect();
let mut out = String::new();
write_row(&mut out, &headers);
write_row(&mut out, &row);
out
}
other => {
let mut out = String::new();
write_row(&mut out, &[cell(other)]);
out
}
}
}

/// If `map` has exactly one entry whose value is an array, return that array.
///
/// `list` payloads wrap their rows under a single key; unwrapping it lets CSV
/// render a real table instead of one opaque, fully-JSON-encoded cell.
fn single_array_value(map: &Map<String, Value>) -> Option<&[Value]> {
if map.len() != 1 {
return None;
}
match map.values().next() {
Some(Value::Array(items)) => Some(items),
_ => None,
}
}

/// Render an array as CSV, choosing a tabular or single-column layout.
fn array_to_csv(items: &[Value]) -> String {
if items.is_empty() {
return String::new();
}

let mut out = String::new();
if items.iter().all(Value::is_object) {
let headers = union_keys(items);
write_row(&mut out, &headers);
for item in items {
let row: Vec<String> = headers
.iter()
.map(|h| {
item.as_object()
.and_then(|o| o.get(h))
.map_or(String::new(), cell)
})
.collect();
write_row(&mut out, &row);
}
} else {
// Heterogeneous or scalar array: one value per row under a single column.
write_row(&mut out, &["value".to_string()]);
for item in items {
write_row(&mut out, &[cell(item)]);
}
}
out
}

/// Collect the union of object keys across `items`, preserving first-seen order.
fn union_keys(items: &[Value]) -> Vec<String> {
let mut keys: Vec<String> = Vec::new();
for item in items {
if let Some(obj) = item.as_object() {
for k in obj.keys() {
if !keys.iter().any(|existing| existing == k) {
keys.push(k.clone());
}
}
}
}
keys
}

/// Convert a single JSON value to its CSV cell text.
fn cell(value: &Value) -> String {
match value {
Value::Null => String::new(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::String(s) => s.clone(),
// Nested structures are encoded as compact JSON to remain lossless.
other => serde_json::to_string(other).unwrap_or_default(),
}
}

/// Append one CSV record (comma-separated, LF-terminated) to `out`.
fn write_row(out: &mut String, fields: &[String]) {
for (i, field) in fields.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&escape(field));
}
out.push('\n');
}

/// Quote a field per RFC 4180 when it contains a comma, quote, or line break.
fn escape(field: &str) -> String {
if field.contains(['"', ',', '\n', '\r']) {
let mut out = String::with_capacity(field.len() + 2);
out.push('"');
out.push_str(&field.replace('"', "\"\""));
out.push('"');
out
} else {
field.to_string()
}
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

#[test]
fn array_of_objects_is_tabular() {
let csv = to_csv(&json!([
{"slug": "google", "name": "Google"},
{"slug": "quad9", "name": "Quad9"},
]));
assert_eq!(csv, "name,slug\nGoogle,google\nQuad9,quad9\n");
}

#[test]
fn missing_key_and_nested_cell() {
let csv = to_csv(&json!([
{"a": "x,y", "b": [1, 2]},
{"a": "hi"},
]));
// "x,y" is quoted; the nested array becomes compact JSON (itself quoted
// because it contains a comma); the missing b cell is blank.
assert_eq!(csv, "a,b\n\"x,y\",\"[1,2]\"\nhi,\n");
}

#[test]
fn array_of_scalars_single_column() {
let csv = to_csv(&json!(["plain", "dot"]));
assert_eq!(csv, "value\nplain\ndot\n");
}

#[test]
fn single_object_one_row() {
let csv = to_csv(&json!({"slug": "google", "name": "Google"}));
assert_eq!(csv, "name,slug\nGoogle,google\n");
}

#[test]
fn bare_scalar_single_cell() {
assert_eq!(to_csv(&json!("hi")), "hi\n");
assert_eq!(to_csv(&json!(42)), "42\n");
}

#[test]
fn empty_array_is_empty() {
assert_eq!(to_csv(&json!([])), "");
}

#[test]
fn embedded_quotes_are_doubled() {
let csv = to_csv(&json!([{"k": "a\"b"}]));
assert_eq!(csv, "k\n\"a\"\"b\"\n");
}

#[test]
fn single_key_array_wrapper_unwraps_to_table() {
// The shape every `dns list` subcommand returns.
let csv = to_csv(&json!({
"providers": [
{"slug": "google", "name": "Google"},
{"slug": "quad9", "name": "Quad9"},
],
}));
assert_eq!(csv, "name,slug\nGoogle,google\nQuad9,quad9\n");
}

#[test]
fn multi_key_object_stays_one_row() {
// A status-style object must not be mistaken for a list wrapper.
let csv = to_csv(&json!({"backend": "systemd", "provider": "cloudflare"}));
assert_eq!(csv, "backend,provider\nsystemd,cloudflare\n");
}

#[test]
fn null_cell_is_blank() {
let csv = to_csv(&json!([{"a": null, "b": "x"}]));
assert_eq!(csv, "a,b\n,x\n");
}
}
59 changes: 45 additions & 14 deletions src/output/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-or-later

pub mod csv;
pub mod envelope;
pub mod human;
pub mod mode;
pub mod trim;
pub mod yaml;

use std::io::{self, Write};

Expand All @@ -15,40 +17,69 @@ pub fn now_utc() -> String {
.to_string()
}

/// Emit structured data to stdout as JSON envelope.
/// Emit structured data to stdout in the selected machine format.
pub fn emit_data<T: serde::Serialize>(data: &T, mode: &mode::OutputMode) {
match mode.format {
mode::Format::Json | mode::Format::Jsonl => {
let mut value = serde_json::to_value(data).unwrap_or(serde_json::Value::Null);
if let Some(ref fields) = mode.fields {
value = trim::trim_value(value, fields);
}
let envelope = envelope::Response::new(value);
mode::Format::Json => {
let envelope = envelope::Response::new(payload_value(data, mode));
let json = serde_json::to_string(&envelope).unwrap_or_else(|_| "{}".to_string());
let _ = writeln!(io::stdout(), "{json}");
}
mode::Format::Jsonl => {
// Newline-delimited JSON: one bare record per line (no repeated
// envelope) so list output streams and stays cheap for agents
// (ACS §8). A non-array payload is emitted as a single line.
let stdout = io::stdout();
let mut handle = stdout.lock();
match payload_value(data, mode) {
serde_json::Value::Array(items) => {
for item in &items {
let line = serde_json::to_string(item).unwrap_or_else(|_| "{}".to_string());
let _ = writeln!(handle, "{line}");
}
}
other => {
let line = serde_json::to_string(&other).unwrap_or_else(|_| "{}".to_string());
let _ = writeln!(handle, "{line}");
}
}
}
mode::Format::Yaml => {
// TODO: implement YAML output
let _ = writeln!(io::stdout(), "# YAML output not yet implemented");
// Mirror the JSON `{ metadata, data }` envelope, then render as YAML.
let envelope = envelope::Response::new(payload_value(data, mode));
let value = serde_json::to_value(&envelope).unwrap_or(serde_json::Value::Null);
// `to_yaml` already terminates with a newline.
let _ = write!(io::stdout(), "{}", yaml::to_yaml(&value));
}
mode::Format::Csv => {
// TODO: implement CSV output
let _ = writeln!(io::stdout(), "# CSV output not yet implemented");
// CSV is tabular: emit the data payload only (the envelope has no row form).
let _ = write!(io::stdout(), "{}", csv::to_csv(&payload_value(data, mode)));
}
mode::Format::Human | mode::Format::Explore => {
// Human output handled per-command
// Human output handled per-command.
}
}
}

/// Serialize `data` to a JSON value, applying `--fields` selection when present.
fn payload_value<T: serde::Serialize>(data: &T, mode: &mode::OutputMode) -> serde_json::Value {
let value = serde_json::to_value(data).unwrap_or(serde_json::Value::Null);
match mode.fields {
Some(ref fields) => trim::trim_payload(value, fields),
None => value,
}
}

/// Emit structured error to stderr.
pub fn emit_error(err: &crate::error::AppError, mode: &mode::OutputMode) {
match mode.format {
mode::Format::Json | mode::Format::Jsonl => {
// All machine formats emit the structured error as JSON on stderr;
// stdout stays reserved for data (PRD §9.7).
mode::Format::Json | mode::Format::Jsonl | mode::Format::Yaml | mode::Format::Csv => {
let json = serde_json::to_string(err).unwrap_or_else(|_| "{}".to_string());
let _ = writeln!(io::stderr(), "{json}");
}
_ => {
mode::Format::Human | mode::Format::Explore => {
let rendered = human::render_error(err, mode.color);
let _ = writeln!(io::stderr(), "{rendered}");
}
Expand Down
Loading
Loading