From 5e8cce657839b0daf69a66eb9acf30867d85de49 Mon Sep 17 00:00:00 2001 From: UnbreakableMJ Date: Thu, 25 Jun 2026 19:07:32 +0300 Subject: [PATCH] feat(output): implement YAML/CSV formats; make list output a bare array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `--format yaml` and `--format csv` stubs with dependency-free serializers over serde_json::Value: - yaml: block-style YAML 1.2 emitter with conservative scalar quoting - csv: RFC 4180 writer; a single-key array wrapper unwraps into a table Adding a YAML crate was avoided deliberately: serde_yaml is deprecated and flagged by RUSTSEC, which would fail the mandatory cargo-audit CI gate (Steelbore Standard §3.3). The Value model is closed, so a small tested emitter suffices with zero dependency-audit surface. List subcommands now return a bare array as `data` instead of a single-key wrapper object, so `--fields` selects columns per record and `--format jsonl` streams one bare record per line (ACS §8). Errors in yaml/csv mode emit structured JSON to stderr, matching json mode (PRD §9.7). Adds unit tests for the yaml/csv emitters and trim_payload, plus integration tests for the array-shaped list payload and jsonl streaming. Co-Authored-By: Claude Opus 4.8 --- src/main.rs | 18 +-- src/output/csv.rs | 219 +++++++++++++++++++++++++++ src/output/mod.rs | 59 ++++++-- src/output/trim.rs | 48 ++++++ src/output/yaml.rs | 301 ++++++++++++++++++++++++++++++++++++++ tests/integration_test.rs | 60 ++++++++ 6 files changed, 682 insertions(+), 23 deletions(-) create mode 100644 src/output/csv.rs create mode 100644 src/output/yaml.rs diff --git a/src/main.rs b/src/main.rs index f8e19c6..8f17d79 100644 --- a/src/main.rs +++ b/src/main.rs @@ -144,13 +144,15 @@ async fn run_cli(cmd: Option, mode: &OutputMode) -> Result = 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) @@ -161,16 +163,14 @@ async fn run_cli(cmd: Option, mode: &OutputMode) -> Result 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 = map.keys().cloned().collect(); + let row: Vec = 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) -> 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 = 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 { + let mut keys: Vec = 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"); + } +} diff --git a/src/output/mod.rs b/src/output/mod.rs index b5df084..b8b7ac1 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -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}; @@ -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(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(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}"); } diff --git a/src/output/trim.rs b/src/output/trim.rs index 3e8b605..a68c630 100644 --- a/src/output/trim.rs +++ b/src/output/trim.rs @@ -29,6 +29,23 @@ pub fn trim_value(value: Value, fields: &[String]) -> Value { Value::Object(result) } +/// Trim a data payload to the requested field paths. +/// +/// Behaves like [`trim_value`], except that an array payload (such as a +/// provider list) has each element trimmed individually, so `--fields` selects +/// columns on list output as well as on single objects. +pub fn trim_payload(value: Value, fields: &[String]) -> Value { + if fields.is_empty() { + return value; + } + match value { + Value::Array(items) => { + Value::Array(items.into_iter().map(|v| trim_value(v, fields)).collect()) + } + other => trim_value(other, fields), + } +} + fn get_nested(value: &Value, path: &[&str]) -> Option { match path.split_first() { None => Some(value.clone()), @@ -168,6 +185,37 @@ mod tests { assert_eq!(trimmed, value); } + #[test] + fn test_trim_payload_applies_to_each_array_element() { + let value = Value::Array(vec![ + obj(&[ + ("slug", Value::String("google".into())), + ("name", Value::String("Google".into())), + ]), + obj(&[ + ("slug", Value::String("quad9".into())), + ("name", Value::String("Quad9".into())), + ]), + ]); + let trimmed = trim_payload(value, &["slug".to_string()]); + let expected = Value::Array(vec![ + obj(&[("slug", Value::String("google".into()))]), + obj(&[("slug", Value::String("quad9".into()))]), + ]); + assert_eq!(trimmed, expected); + } + + #[test] + fn test_trim_payload_object_matches_trim_value() { + let value = obj(&[ + ("slug", Value::String("google".into())), + ("name", Value::String("Google".into())), + ]); + let trimmed = trim_payload(value, &["slug".to_string()]); + let expected = obj(&[("slug", Value::String("google".into()))]); + assert_eq!(trimmed, expected); + } + #[test] fn test_deeply_nested_path() { let deep = obj(&[("value", Value::Number(42.into()))]); diff --git a/src/output/yaml.rs b/src/output/yaml.rs new file mode 100644 index 0000000..817e955 --- /dev/null +++ b/src/output/yaml.rs @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Rust guideline compliant 2026-05-18 + +//! Minimal YAML 1.2 block-style serializer for [`serde_json::Value`] trees. +//! +//! Flux emits its `{ metadata, data }` envelope in several machine formats. +//! Rather than depend on an unmaintained YAML crate — which would trip the +//! mandatory `cargo audit` CI gate (Steelbore Standard §3.3) — we render the +//! already-built JSON value tree directly. The value model is closed (only the +//! six `serde_json::Value` variants), so a small, fully-tested emitter is +//! sufficient and adds zero dependency-audit surface. + +use serde_json::{Map, Value}; + +/// Width, in spaces, of one block-indent level. +const INDENT_STEP: usize = 2; + +/// Render a JSON value as a YAML 1.2 document in block style. +/// +/// The output is terminated by a single trailing newline. Strings are quoted +/// only when a plain scalar would be ambiguous (numbers, booleans, nulls, +/// reserved words, or values containing YAML-significant characters). +pub fn to_yaml(value: &Value) -> String { + let mut out = String::new(); + match value { + Value::Object(map) if !map.is_empty() => write_mapping(&mut out, map, 0, false), + Value::Array(items) if !items.is_empty() => write_sequence(&mut out, items, 0), + // Top-level scalar or empty container renders on a single line. + other => { + out.push_str(&scalar(other)); + out.push('\n'); + } + } + out +} + +/// Write a block mapping at `indent`. +/// +/// When `inline_first` is set the first key is emitted without leading +/// indentation, because the caller has already written a `- ` sequence marker +/// on the current line. +fn write_mapping(out: &mut String, map: &Map, indent: usize, inline_first: bool) { + let mut inline = inline_first; + for (key, val) in map { + if inline { + inline = false; + } else { + pad(out, indent); + } + out.push_str(&scalar_string(key)); + out.push(':'); + write_after_marker(out, val, indent); + } +} + +/// Write a block sequence at `indent`. +fn write_sequence(out: &mut String, items: &[Value], indent: usize) { + for item in items { + match item { + Value::Object(map) if !map.is_empty() => { + pad(out, indent); + out.push_str("- "); + // The first key shares the dash line; the rest align under it. + write_mapping(out, map, indent + INDENT_STEP, true); + } + Value::Array(inner) if !inner.is_empty() => { + pad(out, indent); + out.push_str("-\n"); + write_sequence(out, inner, indent + INDENT_STEP); + } + other => { + pad(out, indent); + out.push_str("- "); + out.push_str(&scalar(other)); + out.push('\n'); + } + } + } +} + +/// Write the value that follows a `key:` or `- ` marker, choosing inline scalar +/// form for leaves and block form for non-empty containers. +fn write_after_marker(out: &mut String, val: &Value, indent: usize) { + match val { + Value::Object(map) if !map.is_empty() => { + out.push('\n'); + write_mapping(out, map, indent + INDENT_STEP, false); + } + Value::Array(items) if !items.is_empty() => { + out.push('\n'); + write_sequence(out, items, indent + INDENT_STEP); + } + other => { + out.push(' '); + out.push_str(&scalar(other)); + out.push('\n'); + } + } +} + +/// Render a leaf value (or an empty container) as a single-line YAML scalar. +fn scalar(value: &Value) -> String { + match value { + Value::Null => "null".to_string(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::String(s) => scalar_string(s), + // Only reached for empty containers; non-empty ones render as blocks. + Value::Array(_) => "[]".to_string(), + Value::Object(_) => "{}".to_string(), + } +} + +/// Quote a string only when a plain scalar would be misread by a YAML parser. +fn scalar_string(s: &str) -> String { + if needs_double_quote(s) { + double_quote(s) + } else if is_plain_safe(s) { + s.to_string() + } else { + single_quote(s) + } +} + +/// True when the string contains characters a single-quoted scalar cannot carry +/// verbatim (control characters, quotes, or backslashes). +fn needs_double_quote(s: &str) -> bool { + s.chars().any(|c| c == '"' || c == '\\' || c.is_control()) +} + +/// True when the string can be emitted as a bare (unquoted) plain scalar. +/// +/// Conservative by design: anything that *might* be reinterpreted is rejected +/// and falls back to a quoted form, which is always valid YAML. +fn is_plain_safe(s: &str) -> bool { + if s.is_empty() || s != s.trim() || is_reserved(s) || looks_like_number(s) { + return false; + } + // Characters that carry special meaning at the start of a plain scalar. + const SPECIAL_START: &[char] = &[ + '-', '?', ':', ',', '[', ']', '{', '}', '#', '&', '*', '!', '|', '>', '\'', '"', '%', '@', + '`', ' ', + ]; + let Some(first) = s.chars().next() else { + return false; + }; + if SPECIAL_START.contains(&first) { + return false; + } + // Sequences that introduce a comment (` #`) or a mapping (`: ` / trailing `:`). + if s.contains(": ") || s.contains(" #") || s.ends_with(':') { + return false; + } + // Restrict the body to an unambiguous, printable subset. + s.chars().all(|c| { + c.is_ascii_alphanumeric() + || matches!(c, ' ' | '.' | '_' | '/' | '+' | '-' | '@' | '(' | ')') + }) +} + +/// True for tokens a YAML 1.1 parser would coerce to a non-string type. +fn is_reserved(s: &str) -> bool { + matches!( + s, + "null" + | "Null" + | "NULL" + | "~" + | "true" + | "True" + | "TRUE" + | "false" + | "False" + | "FALSE" + | "yes" + | "Yes" + | "YES" + | "no" + | "No" + | "NO" + | "on" + | "On" + | "ON" + | "off" + | "Off" + | "OFF" + ) +} + +/// True when the string would parse as a YAML number (and so must be quoted to +/// stay a string). IP addresses such as `8.8.8.8` are not numbers and pass. +fn looks_like_number(s: &str) -> bool { + s.parse::().is_ok() || s.parse::().is_ok() || s.parse::().is_ok() +} + +/// Emit a double-quoted scalar with C-style escapes for control characters. +fn double_quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\t' => out.push_str("\\t"), + '\r' => out.push_str("\\r"), + c if c.is_control() => out.push_str(&format!("\\x{:02X}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +/// Emit a single-quoted scalar, doubling any embedded single quote. +fn single_quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('\''); + out.push_str(&s.replace('\'', "''")); + out.push('\''); + out +} + +/// Append `indent` spaces to `out`. +fn pad(out: &mut String, indent: usize) { + for _ in 0..indent { + out.push(' '); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn flat_mapping() { + let yaml = to_yaml(&json!({"name": "flux", "version": "0.1.0"})); + assert_eq!(yaml, "name: flux\nversion: 0.1.0\n"); + } + + #[test] + fn scalars_are_quoted_when_ambiguous() { + let yaml = to_yaml(&json!({ + "empty": "", + "flag": "true", + "note": "a: b", + "port": "853", + "ratio": "1.0", + })); + assert_eq!( + yaml, + "empty: ''\nflag: 'true'\nnote: 'a: b'\nport: '853'\nratio: '1.0'\n" + ); + } + + #[test] + fn ip_address_stays_plain() { + // Dotted quads are not valid numbers and must not be quoted. + let yaml = to_yaml(&json!({"ipv4": "8.8.8.8"})); + assert_eq!(yaml, "ipv4: 8.8.8.8\n"); + } + + #[test] + fn nested_array_of_objects() { + let yaml = to_yaml(&json!({ + "data": [ + {"slug": "google", "protocols": ["plain", "dot"]}, + {"slug": "quad9"}, + ], + })); + let expected = "data:\n - protocols:\n - plain\n - dot\n slug: google\n - slug: quad9\n"; + assert_eq!(yaml, expected); + } + + #[test] + fn empty_containers_render_inline() { + let yaml = to_yaml(&json!({"a": {}, "b": []})); + assert_eq!(yaml, "a: {}\nb: []\n"); + } + + #[test] + fn numbers_bools_and_null() { + let yaml = to_yaml(&json!({"count": 3, "ok": true, "missing": null})); + // Keys sort alphabetically (serde_json default Map is ordered). + assert_eq!(yaml, "count: 3\nmissing: null\nok: true\n"); + } + + #[test] + fn top_level_scalar() { + assert_eq!(to_yaml(&json!("hi")), "hi\n"); + assert_eq!(to_yaml(&json!(42)), "42\n"); + assert_eq!(to_yaml(&Value::Null), "null\n"); + } + + #[test] + fn control_chars_force_double_quote() { + let yaml = to_yaml(&json!({"x": "a\"b\nc"})); + assert_eq!(yaml, "x: \"a\\\"b\\nc\"\n"); + } +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index c59ce0a..6feb9f3 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -87,6 +87,66 @@ fn test_describe_manifest() { ); } +#[test] +fn test_list_providers_data_is_array() { + // List subcommands return a bare array as `data` (not a wrapper object), + // so `--fields` selects columns per record. + let output = Command::new("cargo") + .args([ + "run", + "--", + "list", + "--providers", + "--json", + "--fields", + "slug", + ]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .env("AI_AGENT", "1") + .output() + .expect("cargo run should succeed"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("\"data\":["), "data should be an array"); + assert!( + stdout.contains("\"slug\""), + "selected field should be present" + ); + assert!( + !stdout.contains("\"name\""), + "unselected field should be trimmed out by --fields" + ); + assert!( + !stdout.contains("\"providers\":"), + "the old single-key wrapper object should be gone" + ); +} + +#[test] +fn test_list_providers_jsonl_streams_records() { + // jsonl emits one bare record per line (no repeated envelope). + let output = Command::new("cargo") + .args(["run", "--", "list", "--providers", "--format", "jsonl"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .env("AI_AGENT", "1") + .output() + .expect("cargo run should succeed"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect(); + assert_eq!(lines.len(), 5, "five providers → five lines"); + for line in &lines { + assert!( + line.starts_with('{') && line.contains("\"slug\""), + "each line is a record: {line}" + ); + assert!( + !line.contains("\"metadata\""), + "jsonl records carry no envelope" + ); + } +} + #[test] fn test_detect_no_panic() { let output = Command::new("cargo")