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