dsp_cli/render/table.rs
1//! Shared rendering helpers for the per-format renderers.
2//!
3//! This module serves three overlapping concerns:
4//!
5//! **Quoting helpers** (`csv_field`): `csv_field` is the RFC-4180 quoting
6//! helper; it lives here (not in csv.rs) so the formula-injection check is
7//! applied consistently. ASCII control-char neutralisation for all three
8//! tabular formats is `crate::util::text::replace_control_chars` (a sibling of
9//! `strip_control_chars`, the prose sanitiser). `render_table_row` is a
10//! test-only helper (the tabular renderers moved to the shared engine in plan
11//! 020).
12//!
13//! **ADR-0007 disclosure/footer writers** (`render_table_disclosure`,
14//! `render_prose_footer`): the auth-state disclosure line written by every
15//! noun method. Tabular formats (lines, csv, tsv) write it to `stderr`;
16//! prose/json carry it on stdout — via a footer (`render_prose_footer`) or the
17//! `_meta.auth` JSON key respectively — using a different sink than tabular.
18//! These helpers centralise the 31-site duplication without adding a new module
19//! (accepted trade-off at plan 019 design review, 2026-06-11).
20//!
21//! **Shared table engine** (`render_table`, `TableSpec`, `TableOptions`,
22//! `HeaderMode`, `QuoteMode`): a projection/header-control engine used by the
23//! csv, tsv, and lines renderers in steps 2–4 of plan 020. Column-set consts
24//! (`PROJECTS_COLUMNS`, etc.) live here as the single source of truth for each
25//! noun group; they feed the engine's unknown-name error hint and the CLI's
26//! `--help` `after_help` text via `crate::render`'s published surface.
27
28use std::io::{self, Write};
29
30use crate::diagnostic::Diagnostic;
31use crate::util::text::replace_control_chars;
32
33use super::MetaContext;
34
35/// Escape a CSV field per RFC 4180: wrap in double-quotes if the value
36/// contains a comma, double-quote, or newline. Internal double-quotes are
37/// escaped by doubling.
38///
39/// **Formula-injection mitigation (spreadsheet safety):** fields that *begin*
40/// with `=`, `+`, `-`, or `@` are also wrapped in quotes. RFC-4180 quoting
41/// does not fully prevent spreadsheet applications from evaluating such fields
42/// as formulas — a leading `=foo` inside `"=foo"` is still formula-eligible in
43/// some apps. We deliberately do **not** prefix-escape (e.g. prefix with `'`)
44/// because that would corrupt the data for legitimate consumers. This is an
45/// accepted residual risk under the personal-CLI threat model where the user
46/// controls the data source. A snapshot fixture locks this behaviour.
47pub(crate) fn csv_field(s: &str) -> String {
48 let needs_quoting = s.contains(',')
49 || s.contains('"')
50 || s.contains('\n')
51 || matches!(s.chars().next(), Some('=' | '+' | '-' | '@'));
52 if needs_quoting {
53 format!("\"{}\"", s.replace('"', "\"\""))
54 } else {
55 s.to_string()
56 }
57}
58
59/// Write the ADR-0007 auth-state disclosure line to `err` (stderr).
60///
61/// Tabular formats (lines, csv, tsv) call this once per noun method, writing
62/// `[{auth_state} on {server_label}]\n` to their stderr sink. Prose and JSON
63/// carry the disclosure on stdout instead — via `render_prose_footer` and the
64/// `_meta.auth` key respectively — so this helper is **tabular formats only**.
65///
66/// Returns `io::Result<()>`. This helper can only fail on IO; that is why it
67/// returns `io::Result` rather than the render layer's usual
68/// `Result<(), Diagnostic>`. If a future change ever needs to surface a non-IO
69/// error here, switch the return type to `Diagnostic` at that point.
70pub(crate) fn render_table_disclosure(err: &mut dyn Write, meta: &MetaContext) -> io::Result<()> {
71 match &meta.filter_warning {
72 None => writeln!(err, "[{} on {}]", meta.auth_state, meta.server_label),
73 Some(fw) => writeln!(err, "[{} on {}] — {fw}", meta.auth_state, meta.server_label),
74 }
75}
76
77/// Write the ADR-0007 footer (blank line then disclosure) to `out` (stdout).
78///
79/// Prose renderer calls this once per noun method. The helper owns the
80/// preceding blank line, so a prose call site is exactly one line. The
81/// disclosure format is `[{auth_state} on {server_label}]\n`, written to
82/// stdout (not stderr) — consistent with prose writing all output to a single
83/// stream.
84///
85/// Returns `io::Result<()>`. This helper can only fail on IO; that is why it
86/// returns `io::Result` rather than the render layer's usual
87/// `Result<(), Diagnostic>`. If a future change ever needs to surface a non-IO
88/// error here, switch the return type to `Diagnostic` at that point.
89pub(crate) fn render_prose_footer(out: &mut dyn Write, meta: &MetaContext) -> io::Result<()> {
90 writeln!(out)?;
91 match &meta.filter_warning {
92 None => writeln!(out, "[{} on {}]", meta.auth_state, meta.server_label),
93 Some(fw) => writeln!(out, "[{} on {}] — {fw}", meta.auth_state, meta.server_label),
94 }
95}
96
97// ── Header/quote mode types ───────────────────────────────────────────────────
98
99/// Controls which rows are emitted by `render_table`.
100///
101/// `On` is the unflagged default: a header row is emitted followed by data rows.
102/// `Off` suppresses the header entirely; only data rows are written.
103/// `Only` emits the header row and no data rows (the action still runs normally —
104/// see D3 in the plan 020 decision record).
105#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
106pub enum HeaderMode {
107 /// Header row + data rows (default).
108 #[default]
109 On,
110 /// Data rows only, no header.
111 Off,
112 /// Header row only, no data rows.
113 Only,
114}
115
116/// Per-format quoting strategy, dispatched inside `render_table`.
117///
118/// All three formats neutralise ASCII control characters via
119/// `replace_control_chars` (ADR-0003), so a server-controlled cell can never
120/// emit a raw ESC/DEL/etc. to the terminal or corrupt the delimited structure.
121/// They differ in separator and additional quoting:
122/// - `Csv` → `","`; `replace_control_chars` then RFC-4180 quoting via `csv_field`
123/// - `Tsv` → `"\t"`; `replace_control_chars` (also prevents an embedded tab/newline
124/// from splitting a column)
125/// - `Lines` → `"\t"`; `replace_control_chars`
126///
127/// No separate separator field exists: the separator is always derived from the
128/// mode so callers cannot accidentally mismatch the two.
129#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130pub(crate) enum QuoteMode {
131 /// Control-char neutralisation + RFC-4180 quoting + formula-injection
132 /// mitigation. Separator: `,`.
133 Csv,
134 /// Control-char neutralisation (all C0 + DEL → space). Separator: `\t`.
135 Tsv,
136 /// Control-char neutralisation (all C0 + DEL → space). Separator: `\t`.
137 Lines,
138}
139
140impl QuoteMode {
141 fn sep(self) -> &'static str {
142 match self {
143 QuoteMode::Csv => ",",
144 QuoteMode::Tsv | QuoteMode::Lines => "\t",
145 }
146 }
147
148 fn apply(self, s: &str) -> String {
149 match self {
150 // Neutralise control chars first, then RFC-4180 quote. After
151 // `replace_control_chars` no `\n` reaches `csv_field`, so its
152 // newline-quoting branch is unreachable from this path (kept
153 // defensively; `csv_field` is not narrowed — out of scope).
154 QuoteMode::Csv => csv_field(&replace_control_chars(s)),
155 QuoteMode::Tsv | QuoteMode::Lines => replace_control_chars(s),
156 }
157 }
158}
159
160// ── TableOptions ─────────────────────────────────────────────────────────────
161
162/// Per-invocation tabular options resolved from the CLI flags by
163/// `FormatArgs::table_options()` (step 4). Lives here so it can be
164/// construction-time validated (syntax only — unknown-name validation
165/// happens inside the engine where the per-noun column set is known).
166///
167/// `Default` gives the unflagged behaviour: all columns, header on.
168#[derive(Debug, Default)]
169pub struct TableOptions {
170 /// User-supplied `--columns` selection, validated for syntax by
171 /// `table_options()`: non-empty, no blank segments, no duplicates.
172 /// `None` means the flag was not supplied.
173 pub columns: Option<Vec<String>>,
174 /// Resolved header mode. `On` is the unflagged default.
175 pub header: HeaderMode,
176}
177
178impl TableOptions {
179 /// Borrow the column projection as `Option<Vec<&str>>`, ready to pass to
180 /// `TableSpec::projected`.
181 ///
182 /// Returns `None` when `--columns` was not supplied (engine falls through
183 /// to `default_columns` or `all_columns`). Returns `Some(vec)` when the
184 /// flag was supplied; each element borrows from `self.columns`.
185 pub fn projected(&self) -> Option<Vec<&str>> {
186 self.columns
187 .as_ref()
188 .map(|c| c.iter().map(String::as_str).collect())
189 }
190}
191
192// ── Per-noun column-set consts ────────────────────────────────────────────────
193//
194// Single source of truth for each noun group's column set. These consts are:
195// (a) referenced by `TableSpec.all_columns` in each renderer method body,
196// (b) used by the engine to build the unknown-column error hint (names appear
197// in declaration order, giving a stable, snapshot-stable message), and
198// (c) re-exported through `crate::render`'s published surface for the
199// `after_help` drift-guard tests in the CLI layer (step 5).
200//
201// Column order follows the CSV header order in csv.rs (the authoritative set).
202
203/// Column set for `project list` and `project describe`.
204pub(crate) const PROJECTS_COLUMNS: &[&str] = &[
205 "shortcode",
206 "shortname",
207 "longname",
208 "status",
209 "data_models",
210 "iri",
211];
212
213/// Column set for `data-model list`.
214pub(crate) const DATA_MODELS_COLUMNS: &[&str] =
215 &["name", "iri", "label", "last_modified", "is_builtin"];
216
217/// Column set for `data-model describe`.
218pub(crate) const DATA_MODEL_DESCRIBE_COLUMNS: &[&str] =
219 &["name", "iri", "label", "last_modified", "resource_types"];
220
221/// Column set for `resource-type list`.
222pub(crate) const RESOURCE_TYPES_COLUMNS: &[&str] = &["name", "iri", "label", "is_builtin"];
223
224/// Column set for `resource-type describe` (one row per field).
225///
226/// The full set is 8 columns: `iri` is at position 1 (after `name`), matching
227/// the lines renderer's lean default of `["name", "iri"]`. csv/tsv use this set
228/// with `default_columns: Some(&["name","value_type","link_target","cardinality",
229/// "label","is_builtin","data_model"])` — a 7-column lean default identical to
230/// the pre-020 csv/tsv header (byte-identical output for unflagged invocations).
231/// The `iri` column is unlocked via `--columns iri` on all three formats.
232pub(crate) const RESOURCE_TYPE_DESCRIBE_COLUMNS: &[&str] = &[
233 "name",
234 "iri",
235 "value_type",
236 "link_target",
237 "cardinality",
238 "label",
239 "is_builtin",
240 "data_model",
241];
242
243/// Lean default subset for `resource-type describe` csv/tsv (matches the
244/// pre-020 7-column csv/tsv header; `iri` is hidden by default, accessible
245/// via `--columns iri`).
246pub(crate) const RESOURCE_TYPE_DESCRIBE_DEFAULT_COLUMNS: &[&str] = &[
247 "name",
248 "value_type",
249 "link_target",
250 "cardinality",
251 "label",
252 "is_builtin",
253 "data_model",
254];
255
256/// Column set for `resource list`.
257///
258/// Default columns = all (no lean default const). All six columns are shown
259/// in every tabular format (matching `project list` / `resource-type list`).
260pub(crate) const RESOURCE_LIST_COLUMNS: &[&str] = &[
261 "label",
262 "iri",
263 "ark_url",
264 "creation_date",
265 "last_modified",
266 "resource_type",
267];
268
269/// Column set for `resource describe`.
270///
271/// Default columns = all (no lean default const). All ten columns are shown
272/// in every tabular format, mirroring `resource list`. `None` fields render
273/// as empty strings in tabular output.
274pub(crate) const RESOURCE_DESCRIBE_COLUMNS: &[&str] = &[
275 "label",
276 "iri",
277 "resource_type",
278 "ark_url",
279 "creation_date",
280 "last_modified",
281 "attached_project",
282 "owner",
283 "visibility",
284 "your_access",
285];
286
287/// Column set for `resource describe --values` (long-format, one row per
288/// value). `label`/`iri` are the leading key columns (ADR-0013 option 1).
289pub(crate) const RESOURCE_DESCRIBE_VALUES_COLUMNS: &[&str] = &[
290 "label",
291 "iri",
292 "field",
293 "field_label",
294 "value_type",
295 "value",
296 "comment",
297];
298
299/// Default columns for `resource describe --values` (all three tabular
300/// formats). `label`/`iri` are omitted by default — they are constant across
301/// every value row of a single-resource describe, so repeating them is pure
302/// redundancy; they stay available via `--columns label,iri,…` for callers
303/// who want self-contained/greppable rows.
304pub(crate) const RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS: &[&str] =
305 &["field", "field_label", "value_type", "value"];
306
307/// Column set for `data-model structure`.
308pub(crate) const DATA_MODEL_STRUCTURE_COLUMNS: &[&str] =
309 &["source", "target", "kind", "field", "target_data_model"];
310
311/// Column set for `auth login`, `auth status`, and `auth set-token`.
312pub(crate) const AUTH_LOGIN_COLUMNS: &[&str] = &["server", "user", "expires_at", "state"];
313
314/// Column set for `auth logout`.
315pub(crate) const AUTH_LOGOUT_COLUMNS: &[&str] = &["server", "was_cached"];
316
317/// Column set for `project dump`.
318pub(crate) const PROJECT_DUMP_COLUMNS: &[&str] = &["path"];
319
320/// Column set for `project dump --delete`.
321pub(crate) const PROJECT_DUMP_DELETED_COLUMNS: &[&str] = &["deleted"];
322
323// ── TableSpec and render_table ────────────────────────────────────────────────
324
325/// One table to render, described by named fields.
326///
327/// ## Column-set precedence (engine contract)
328///
329/// The effective column set is chosen in this order:
330///
331/// 1. `projected` — user-supplied `--columns` selection (select AND reorder).
332/// 2. `default_columns` — the lean subset used by the lines renderer when no
333/// `--columns` flag is given. `Some(&[])` means zero columns (degenerate
334/// case — the engine emits nothing for data rows). `None` means "same as
335/// `all_columns`".
336/// 3. `all_columns` — the full set, used when neither of the above is present.
337///
338/// This contract is pinned by unit tests in this module.
339pub(crate) struct TableSpec<'a> {
340 /// Full column set in csv-header declaration order. Used as the valid-name
341 /// registry for unknown-column error hints.
342 pub all_columns: &'a [&'a str],
343 /// Full-width data rows. Each `Vec<String>` must have the same length as
344 /// `all_columns`; the engine indexes into it by position.
345 pub rows: &'a [Vec<String>],
346 /// Lines renderer's lean default subset; `None` = all columns. See
347 /// precedence contract above. `Some(&[])` → zero columns.
348 pub default_columns: Option<&'a [&'a str]>,
349 /// User's `--columns` selection, borrowed from `TableOptions::columns`.
350 /// Build with `options.projected()` — the `TableOptions::projected()` helper
351 /// returns the `Option<Vec<&str>>` ready to assign here.
352 /// `None` = flag not supplied; the engine falls through to `default_columns`
353 /// or `all_columns`.
354 pub projected: Option<Vec<&'a str>>,
355 /// Quoting strategy; also determines the column separator (no separate `sep`
356 /// field — the engine derives it to prevent caller mismatches).
357 pub quote: QuoteMode,
358 /// Effective header mode. The **caller** computes this:
359 /// - csv/tsv pass `options.header` directly.
360 /// - lines passes `HeaderMode::Off` unconditionally (upstream validation
361 /// prevents the user from setting header flags with lines; the engine
362 /// never sees two authoritative header sources).
363 pub header: HeaderMode,
364}
365
366/// Render a table to `out` according to `spec`.
367///
368/// ## Validation (runs before any output is written)
369///
370/// If `spec.projected` contains a name not in `spec.all_columns`, returns
371/// `Diagnostic::Usage` naming the unknown column and listing the valid names
372/// in `all_columns` declaration order. Nothing is written to `out` before
373/// this check completes — including in `HeaderMode::Only`.
374///
375/// ## Emission
376///
377/// - `HeaderMode::On`: header row, then data rows.
378/// - `HeaderMode::Off`: data rows only.
379/// - `HeaderMode::Only`: header row only (no data rows emitted regardless of
380/// `spec.rows`).
381///
382/// The effective column set is resolved per the precedence contract on
383/// [`TableSpec`]. Column cells in data rows are selected and reordered to
384/// match the effective column set; quoting is applied per `spec.quote` to
385/// data cells. Header cells are written as plain literals (no quoting).
386pub(crate) fn render_table(out: &mut dyn Write, spec: &TableSpec<'_>) -> Result<(), Diagnostic> {
387 // Invariant: default_columns, when present, must be a subset of all_columns.
388 // This is a renderer-layer contract (callers supply the consts); a violation
389 // is an internal defect, not a user error.
390 debug_assert!(
391 spec.default_columns
392 .map(|defs| defs.iter().all(|n| spec.all_columns.contains(n)))
393 .unwrap_or(true),
394 "default_columns must be a subset of all_columns (internal invariant)"
395 );
396
397 // Resolve effective column set.
398 let effective_columns: &[&str] = if let Some(ref proj) = spec.projected {
399 // Validate all projected names before writing anything.
400 for name in proj.iter() {
401 if !spec.all_columns.contains(name) {
402 let valid = spec.all_columns.join(", ");
403 return Err(Diagnostic::Usage(format!(
404 "unknown column \"{name}\"; valid columns: {valid}"
405 )));
406 }
407 }
408 proj.as_slice()
409 } else if let Some(defaults) = spec.default_columns {
410 defaults
411 } else {
412 spec.all_columns
413 };
414
415 let sep = spec.quote.sep();
416
417 // Build index map: effective column name → position in all_columns.
418 // We use a Vec<usize> aligned to effective_columns for row projection.
419 //
420 // After the validation above, every name in effective_columns is guaranteed
421 // to be in all_columns (projected names are validated above; default_columns
422 // and all_columns are renderer-layer consts). `.position(…)` should always
423 // succeed here. If it does not, that is an internal invariant breach — return
424 // an Internal diagnostic rather than panic (no unwrap/expect in non-test code).
425 let col_indices: Vec<usize> = effective_columns
426 .iter()
427 .map(|name| {
428 spec.all_columns
429 .iter()
430 .position(|c| c == name)
431 .ok_or_else(|| {
432 Diagnostic::Internal(format!(
433 "column index missing for \"{name}\" after validation \
434 (all_columns=[{}]); this is a dsp-cli bug",
435 spec.all_columns.join(", ")
436 ))
437 })
438 })
439 .collect::<Result<Vec<_>, _>>()?;
440
441 // Header row.
442 if matches!(spec.header, HeaderMode::On | HeaderMode::Only) {
443 let header_line = effective_columns.join(sep);
444 writeln!(out, "{header_line}")?;
445 }
446
447 // Data rows (skip entirely for HeaderMode::Only).
448 if !matches!(spec.header, HeaderMode::Only) {
449 for row in spec.rows {
450 let mut cells: Vec<String> = Vec::with_capacity(col_indices.len());
451 for &idx in &col_indices {
452 // Same no-panic policy as the column-index map above: a row
453 // narrower than all_columns is an internal defect, not a
454 // user error.
455 let value = row.get(idx).ok_or_else(|| {
456 Diagnostic::Internal(format!(
457 "row has {} cells, expected {} (column index {idx} out of \
458 range); this is a dsp-cli bug",
459 row.len(),
460 spec.all_columns.len()
461 ))
462 })?;
463 cells.push(spec.quote.apply(value));
464 }
465 writeln!(out, "{}", cells.join(sep))?;
466 }
467 }
468
469 Ok(())
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 // ── render_table_row (test-only helper) ───────────────────────────────────
477 //
478 // render_table_row was a pub(crate) helper used only by the pre-020 CSV/TSV
479 // renderer paths. The tabular renderers now route through the shared engine
480 // (render_table). This function is preserved here for the tests that pin the
481 // quoting and joining behaviour directly (which are still useful as unit
482 // coverage for csv_field and replace_control_chars via the join path).
483
484 fn render_table_row(fields: &[&str], sep: &str, quote: impl Fn(&str) -> String) -> String {
485 fields
486 .iter()
487 .map(|f| quote(f))
488 .collect::<Vec<_>>()
489 .join(sep)
490 }
491
492 // ── render_table_disclosure ───────────────────────────────────────────────
493
494 #[test]
495 fn table_disclosure_anonymous_prod() {
496 let meta = MetaContext {
497 auth_state: "anonymous".to_string(),
498 server_label: "prod".to_string(),
499 filter_warning: None,
500 };
501 let mut buf: Vec<u8> = Vec::new();
502 render_table_disclosure(&mut buf, &meta).unwrap();
503 assert_eq!(buf, b"[anonymous on prod]\n");
504 }
505
506 // ── render_prose_footer ───────────────────────────────────────────────────
507
508 #[test]
509 fn prose_footer_authenticated_test() {
510 let meta = MetaContext {
511 auth_state: "authenticated as you@dasch.swiss".to_string(),
512 server_label: "test".to_string(),
513 filter_warning: None,
514 };
515 let mut buf: Vec<u8> = Vec::new();
516 render_prose_footer(&mut buf, &meta).unwrap();
517 assert_eq!(buf, b"\n[authenticated as you@dasch.swiss on test]\n");
518 }
519
520 // ── render_table_row ──────────────────────────────────────────────────────
521
522 #[test]
523 fn table_row_csv_no_quoting_needed() {
524 let result = render_table_row(&["abc", "def", "ghi"], ",", csv_field);
525 assert_eq!(result, "abc,def,ghi");
526 }
527
528 #[test]
529 fn table_row_csv_comma_in_field() {
530 let result = render_table_row(&["foo", "bar,baz", "qux"], ",", csv_field);
531 assert_eq!(result, r#"foo,"bar,baz",qux"#);
532 }
533
534 #[test]
535 fn table_row_csv_quote_in_field() {
536 let result = render_table_row(&[r#"say "hello""#], ",", csv_field);
537 assert_eq!(result, r#""say ""hello""" "#.trim());
538 }
539
540 #[test]
541 fn table_row_csv_newline_in_field() {
542 let result = render_table_row(&["line1\nline2"], ",", csv_field);
543 assert_eq!(result, "\"line1\nline2\"");
544 }
545
546 #[test]
547 fn table_row_identity_tab_separator() {
548 let result = render_table_row(&["alpha", "beta", "gamma"], "\t", |s: &str| s.to_string());
549 assert_eq!(result, "alpha\tbeta\tgamma");
550 }
551
552 #[test]
553 fn table_row_identity_comma_separator() {
554 // identity closure: no quoting applied, comma not escaped
555 let result = render_table_row(&["a,b", "c"], ",", |s: &str| s.to_string());
556 assert_eq!(result, "a,b,c");
557 }
558
559 #[test]
560 fn table_row_empty_fields() {
561 let result = render_table_row(&["", "x", ""], "\t", |s: &str| s.to_string());
562 assert_eq!(result, "\tx\t");
563 }
564
565 // ── csv_field quoting triggers ────────────────────────────────────────────
566
567 #[test]
568 fn csv_field_plain_string() {
569 assert_eq!(csv_field("hello"), "hello");
570 }
571
572 #[test]
573 fn csv_field_contains_comma() {
574 assert_eq!(csv_field("a,b"), "\"a,b\"");
575 }
576
577 #[test]
578 fn csv_field_contains_quote() {
579 assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
580 }
581
582 #[test]
583 fn csv_field_contains_newline() {
584 assert_eq!(csv_field("a\nb"), "\"a\nb\"");
585 }
586
587 #[test]
588 fn csv_field_leading_equals_formula_injection() {
589 assert_eq!(csv_field("=SUM(A1:A10)"), "\"=SUM(A1:A10)\"");
590 }
591
592 #[test]
593 fn csv_field_leading_plus_formula_injection() {
594 assert_eq!(csv_field("+1"), "\"+1\"");
595 }
596
597 #[test]
598 fn csv_field_leading_minus_formula_injection() {
599 assert_eq!(csv_field("-1"), "\"-1\"");
600 }
601
602 #[test]
603 fn csv_field_leading_at_formula_injection() {
604 assert_eq!(csv_field("@SUM"), "\"@SUM\"");
605 }
606
607 #[test]
608 fn csv_field_not_leading_equals() {
609 // `=` in the middle is not a formula trigger
610 assert_eq!(csv_field("a=b"), "a=b");
611 }
612
613 #[test]
614 fn csv_field_empty_string() {
615 assert_eq!(csv_field(""), "");
616 }
617
618 // (control-char sanitiser tests moved to `crate::util::text` as
619 // `replace_control_chars_*` when the helper was relocated there.)
620
621 // ── render_table: header modes ────────────────────────────────────────────
622
623 fn make_spec<'a>(
624 all: &'a [&'a str],
625 rows: &'a [Vec<String>],
626 projected: Option<Vec<&'a str>>,
627 defaults: Option<&'a [&'a str]>,
628 quote: QuoteMode,
629 header: HeaderMode,
630 ) -> TableSpec<'a> {
631 TableSpec {
632 all_columns: all,
633 rows,
634 default_columns: defaults,
635 projected,
636 quote,
637 header,
638 }
639 }
640
641 #[test]
642 fn header_mode_on_non_empty_rows() {
643 let all = &["a", "b"];
644 let rows = vec![
645 vec!["1".to_string(), "2".to_string()],
646 vec!["3".to_string(), "4".to_string()],
647 ];
648 let mut buf: Vec<u8> = Vec::new();
649 render_table(
650 &mut buf,
651 &make_spec(all, &rows, None, None, QuoteMode::Tsv, HeaderMode::On),
652 )
653 .unwrap();
654 assert_eq!(String::from_utf8(buf).unwrap(), "a\tb\n1\t2\n3\t4\n");
655 }
656
657 #[test]
658 fn header_mode_on_empty_rows() {
659 let all = &["x", "y"];
660 let rows: Vec<Vec<String>> = vec![];
661 let mut buf: Vec<u8> = Vec::new();
662 render_table(
663 &mut buf,
664 &make_spec(all, &rows, None, None, QuoteMode::Tsv, HeaderMode::On),
665 )
666 .unwrap();
667 // Header only, no data rows.
668 assert_eq!(String::from_utf8(buf).unwrap(), "x\ty\n");
669 }
670
671 #[test]
672 fn header_mode_off_non_empty_rows() {
673 let all = &["a", "b"];
674 let rows = vec![vec!["1".to_string(), "2".to_string()]];
675 let mut buf: Vec<u8> = Vec::new();
676 render_table(
677 &mut buf,
678 &make_spec(all, &rows, None, None, QuoteMode::Tsv, HeaderMode::Off),
679 )
680 .unwrap();
681 assert_eq!(String::from_utf8(buf).unwrap(), "1\t2\n");
682 }
683
684 #[test]
685 fn header_mode_off_empty_rows_yields_zero_bytes() {
686 let all = &["a", "b"];
687 let rows: Vec<Vec<String>> = vec![];
688 let mut buf: Vec<u8> = Vec::new();
689 render_table(
690 &mut buf,
691 &make_spec(all, &rows, None, None, QuoteMode::Tsv, HeaderMode::Off),
692 )
693 .unwrap();
694 assert_eq!(buf, b"");
695 }
696
697 #[test]
698 fn header_mode_only_non_empty_rows() {
699 // Only mode: header emitted but data rows suppressed regardless of rows.
700 let all = &["a", "b"];
701 let rows = vec![vec!["1".to_string(), "2".to_string()]];
702 let mut buf: Vec<u8> = Vec::new();
703 render_table(
704 &mut buf,
705 &make_spec(all, &rows, None, None, QuoteMode::Tsv, HeaderMode::Only),
706 )
707 .unwrap();
708 assert_eq!(String::from_utf8(buf).unwrap(), "a\tb\n");
709 }
710
711 #[test]
712 fn header_mode_only_empty_rows() {
713 let all = &["a", "b"];
714 let rows: Vec<Vec<String>> = vec![];
715 let mut buf: Vec<u8> = Vec::new();
716 render_table(
717 &mut buf,
718 &make_spec(all, &rows, None, None, QuoteMode::Tsv, HeaderMode::Only),
719 )
720 .unwrap();
721 // Header row only.
722 assert_eq!(String::from_utf8(buf).unwrap(), "a\tb\n");
723 }
724
725 // ── render_table: lines mode (QuoteMode::Lines + HeaderMode::Off) ─────────
726
727 #[test]
728 fn lines_mode_off_emits_no_header() {
729 let all = &["name", "label"];
730 let rows = vec![vec!["foo".to_string(), "bar".to_string()]];
731 let mut buf: Vec<u8> = Vec::new();
732 render_table(
733 &mut buf,
734 &make_spec(all, &rows, None, None, QuoteMode::Lines, HeaderMode::Off),
735 )
736 .unwrap();
737 // No header, one tab-separated data row.
738 assert_eq!(String::from_utf8(buf).unwrap(), "foo\tbar\n");
739 }
740
741 #[test]
742 fn lines_mode_applies_replace_control_chars() {
743 let all = &["name"];
744 let rows = vec![vec!["a\tb".to_string()]];
745 let mut buf: Vec<u8> = Vec::new();
746 render_table(
747 &mut buf,
748 &make_spec(all, &rows, None, None, QuoteMode::Lines, HeaderMode::Off),
749 )
750 .unwrap();
751 // Tab in value must be replaced by space.
752 assert_eq!(String::from_utf8(buf).unwrap(), "a b\n");
753 }
754
755 // ── render_table: csv/tsv control-char neutralisation (ADR-0003) ──────────
756
757 #[test]
758 fn csv_mode_neutralises_control_chars() {
759 let all = &["name"];
760 let rows = vec![vec!["a\u{1b}\t\n\u{7f}b".to_string()]];
761 let mut buf: Vec<u8> = Vec::new();
762 render_table(
763 &mut buf,
764 &make_spec(all, &rows, None, None, QuoteMode::Csv, HeaderMode::Off),
765 )
766 .unwrap();
767 // ESC, tab, newline, DEL each become a space; nothing left needs quoting.
768 assert_eq!(String::from_utf8(buf).unwrap(), "a b\n");
769 }
770
771 #[test]
772 fn tsv_mode_neutralises_control_chars() {
773 let all = &["name"];
774 let rows = vec![vec!["a\u{1b}\u{7f}b".to_string()]];
775 let mut buf: Vec<u8> = Vec::new();
776 render_table(
777 &mut buf,
778 &make_spec(all, &rows, None, None, QuoteMode::Tsv, HeaderMode::Off),
779 )
780 .unwrap();
781 // Previously TSV was identity and emitted ESC/DEL raw; now neutralised.
782 assert_eq!(String::from_utf8(buf).unwrap(), "a b\n");
783 }
784
785 #[test]
786 fn tsv_embedded_tab_does_not_split_column() {
787 // Two columns; the first value contains a tab. Previously (identity) the
788 // embedded tab created a spurious extra column; now it becomes a space,
789 // so the row has exactly one separator tab (between the two columns).
790 let all = &["a", "b"];
791 let rows = vec![vec!["x\ty".to_string(), "z".to_string()]];
792 let mut buf: Vec<u8> = Vec::new();
793 render_table(
794 &mut buf,
795 &make_spec(all, &rows, None, None, QuoteMode::Tsv, HeaderMode::Off),
796 )
797 .unwrap();
798 assert_eq!(String::from_utf8(buf).unwrap(), "x y\tz\n");
799 }
800
801 #[test]
802 fn csv_leading_control_then_formula_char_not_quoted() {
803 // A leading control char becomes a leading space, so the '=' is no longer
804 // first and csv_field's formula-injection guard does not fire. This is
805 // SAFE: a leading space defuses spreadsheet formula evaluation. Pinned so
806 // the composition's behaviour can't silently regress.
807 let all = &["name"];
808 let rows = vec![vec!["\u{1b}=SUM(A1)".to_string()]];
809 let mut buf: Vec<u8> = Vec::new();
810 render_table(
811 &mut buf,
812 &make_spec(all, &rows, None, None, QuoteMode::Csv, HeaderMode::Off),
813 )
814 .unwrap();
815 assert_eq!(String::from_utf8(buf).unwrap(), " =SUM(A1)\n");
816 }
817
818 // ── render_table: projection select and reorder ──────────────────────────
819
820 #[test]
821 fn projection_selects_subset() {
822 let all = &["a", "b", "c"];
823 let rows = vec![vec!["1".to_string(), "2".to_string(), "3".to_string()]];
824 let mut buf: Vec<u8> = Vec::new();
825 render_table(
826 &mut buf,
827 &make_spec(
828 all,
829 &rows,
830 Some(vec!["a", "c"]),
831 None,
832 QuoteMode::Tsv,
833 HeaderMode::On,
834 ),
835 )
836 .unwrap();
837 assert_eq!(String::from_utf8(buf).unwrap(), "a\tc\n1\t3\n");
838 }
839
840 #[test]
841 fn projection_reorders_columns() {
842 // User asks for c,a — engine must honour user order, not all_columns order.
843 let all = &["a", "b", "c"];
844 let rows = vec![vec!["1".to_string(), "2".to_string(), "3".to_string()]];
845 let mut buf: Vec<u8> = Vec::new();
846 render_table(
847 &mut buf,
848 &make_spec(
849 all,
850 &rows,
851 Some(vec!["c", "a"]),
852 None,
853 QuoteMode::Tsv,
854 HeaderMode::On,
855 ),
856 )
857 .unwrap();
858 assert_eq!(String::from_utf8(buf).unwrap(), "c\ta\n3\t1\n");
859 }
860
861 #[test]
862 fn projection_csv_quoting_applied_to_data_cells() {
863 // A projected value that contains a comma must be csv_field-quoted.
864 let all = &["name", "note"];
865 let rows = vec![vec!["foo".to_string(), "a,b".to_string()]];
866 let mut buf: Vec<u8> = Vec::new();
867 render_table(
868 &mut buf,
869 &make_spec(
870 all,
871 &rows,
872 Some(vec!["name", "note"]),
873 None,
874 QuoteMode::Csv,
875 HeaderMode::On,
876 ),
877 )
878 .unwrap();
879 // Data cell with comma gets quoted; header cells are plain literals.
880 assert_eq!(String::from_utf8(buf).unwrap(), "name,note\nfoo,\"a,b\"\n");
881 }
882
883 #[test]
884 fn projection_header_cells_are_plain_literals() {
885 // Header must not be csv_field-quoted even if the column name contained a
886 // comma (column names never do in practice, but the engine must not quote).
887 let all = &["shortcode", "longname"];
888 let rows = vec![vec!["0001".to_string(), "Project One".to_string()]];
889 let mut buf: Vec<u8> = Vec::new();
890 render_table(
891 &mut buf,
892 &make_spec(all, &rows, None, None, QuoteMode::Csv, HeaderMode::On),
893 )
894 .unwrap();
895 // Header is plain, data is quoted only if needed.
896 assert_eq!(
897 String::from_utf8(buf).unwrap(),
898 "shortcode,longname\n0001,Project One\n"
899 );
900 }
901
902 // ── render_table: unknown-column error ───────────────────────────────────
903
904 #[test]
905 fn unknown_column_returns_usage_error_with_valid_names() {
906 let all = &["shortcode", "shortname", "iri"];
907 let rows: Vec<Vec<String>> = vec![];
908 let mut buf: Vec<u8> = Vec::new();
909 let result = render_table(
910 &mut buf,
911 &make_spec(
912 all,
913 &rows,
914 Some(vec!["shortcode", "xyz"]),
915 None,
916 QuoteMode::Csv,
917 HeaderMode::On,
918 ),
919 );
920 let err = result.unwrap_err();
921 let msg = err.to_string();
922 // Message must name the unknown column.
923 assert!(msg.contains("\"xyz\""), "message: {msg}");
924 // Message must list valid names.
925 assert!(msg.contains("shortcode"), "message: {msg}");
926 assert!(msg.contains("shortname"), "message: {msg}");
927 assert!(msg.contains("iri"), "message: {msg}");
928 }
929
930 #[test]
931 fn unknown_column_error_before_any_output() {
932 // Nothing must be written to `out` before the error is returned.
933 let all = &["a", "b"];
934 let rows = vec![vec!["1".to_string(), "2".to_string()]];
935 let mut buf: Vec<u8> = Vec::new();
936 let result = render_table(
937 &mut buf,
938 &make_spec(
939 all,
940 &rows,
941 Some(vec!["a", "z"]),
942 None,
943 QuoteMode::Csv,
944 HeaderMode::On,
945 ),
946 );
947 assert!(result.is_err());
948 assert_eq!(buf, b"", "output must be empty when validation fails");
949 }
950
951 #[test]
952 fn unknown_column_error_before_output_with_header_only_mode() {
953 // Even HeaderMode::Only must not write a header if validation fails.
954 let all = &["a", "b"];
955 let rows: Vec<Vec<String>> = vec![];
956 let mut buf: Vec<u8> = Vec::new();
957 let result = render_table(
958 &mut buf,
959 &make_spec(
960 all,
961 &rows,
962 Some(vec!["a", "unknown"]),
963 None,
964 QuoteMode::Csv,
965 HeaderMode::Only,
966 ),
967 );
968 assert!(result.is_err());
969 assert_eq!(buf, b"", "no header must be emitted before error");
970 }
971
972 // ── render_table: column-set precedence ──────────────────────────────────
973
974 #[test]
975 fn projected_overrides_default_columns() {
976 // default_columns = Some(&["a"]), but projected asks for "b" — projected wins.
977 let all = &["a", "b"];
978 let rows = vec![vec!["val_a".to_string(), "val_b".to_string()]];
979 let mut buf: Vec<u8> = Vec::new();
980 render_table(
981 &mut buf,
982 &make_spec(
983 all,
984 &rows,
985 Some(vec!["b"]),
986 Some(&["a"]),
987 QuoteMode::Tsv,
988 HeaderMode::On,
989 ),
990 )
991 .unwrap();
992 assert_eq!(String::from_utf8(buf).unwrap(), "b\nval_b\n");
993 }
994
995 #[test]
996 fn default_columns_used_when_no_projection() {
997 // default_columns = Some(&["a"]) and no projection → only "a" column.
998 let all = &["a", "b"];
999 let rows = vec![vec!["val_a".to_string(), "val_b".to_string()]];
1000 let mut buf: Vec<u8> = Vec::new();
1001 render_table(
1002 &mut buf,
1003 &make_spec(
1004 all,
1005 &rows,
1006 None,
1007 Some(&["a"]),
1008 QuoteMode::Tsv,
1009 HeaderMode::On,
1010 ),
1011 )
1012 .unwrap();
1013 assert_eq!(String::from_utf8(buf).unwrap(), "a\nval_a\n");
1014 }
1015
1016 #[test]
1017 fn default_columns_none_means_all_columns() {
1018 // default_columns = None → falls through to all_columns.
1019 let all = &["a", "b"];
1020 let rows = vec![vec!["1".to_string(), "2".to_string()]];
1021 let mut buf: Vec<u8> = Vec::new();
1022 render_table(
1023 &mut buf,
1024 &make_spec(all, &rows, None, None, QuoteMode::Tsv, HeaderMode::On),
1025 )
1026 .unwrap();
1027 assert_eq!(String::from_utf8(buf).unwrap(), "a\tb\n1\t2\n");
1028 }
1029
1030 #[test]
1031 fn default_columns_some_empty_means_zero_columns() {
1032 // default_columns = Some(&[]) → zero effective columns → empty data lines.
1033 // HeaderMode::Off so we test the row output path.
1034 let all = &["a", "b"];
1035 let rows = vec![vec!["1".to_string(), "2".to_string()]];
1036 let mut buf: Vec<u8> = Vec::new();
1037 render_table(
1038 &mut buf,
1039 &make_spec(all, &rows, None, Some(&[]), QuoteMode::Tsv, HeaderMode::Off),
1040 )
1041 .unwrap();
1042 // Zero columns → each row emits an empty joined string + newline.
1043 assert_eq!(String::from_utf8(buf).unwrap(), "\n");
1044 }
1045
1046 #[test]
1047 fn default_columns_some_empty_with_header_on() {
1048 // default_columns = Some(&[]) with HeaderMode::On: the engine emits an
1049 // empty header line (join of zero columns = "") followed by one empty data
1050 // line per row (same writeln behaviour as HeaderMode::Off for data rows).
1051 // This pins the natural engine output; no engine behaviour is changed here.
1052 let all = &["a", "b"];
1053 let rows = vec![
1054 vec!["1".to_string(), "2".to_string()],
1055 vec!["3".to_string(), "4".to_string()],
1056 ];
1057 let mut buf: Vec<u8> = Vec::new();
1058 render_table(
1059 &mut buf,
1060 &make_spec(all, &rows, None, Some(&[]), QuoteMode::Csv, HeaderMode::On),
1061 )
1062 .unwrap();
1063 // Empty header line + two empty data lines (one per row).
1064 assert_eq!(String::from_utf8(buf).unwrap(), "\n\n\n");
1065 }
1066}