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