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}