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). 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}