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