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}