Skip to main content

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}