Skip to main content

dsp_cli/render/
lines.rs

1//! Lines renderer — one identifier per line.
2//!
3//! Lines output emits one identifier per line for easy shell pipeline
4//! consumption (e.g. piping into `xargs`). Auth methods emit:
5//! - `auth_login` / `auth_status` / `auth_set_token`: the server URL.
6//! - `auth_logout`: `<server>\twas_cached=true|false` (tab-separated, bespoke decorated default;
7//!   projected path goes through the engine).
8//!
9//! Per-noun methods arrive with real data: `project_dump` in Phase 3,
10//! `projects` (list) in Phase 4; other noun-groups (data-models, etc.) in Phase 5.
11
12use std::io::{self, Write};
13
14use crate::diagnostic::Diagnostic;
15use crate::model::{DataModelDetail, DataModelStructure, ProjectDetail, VocabularyDetail};
16use crate::render::auth::{AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome};
17use crate::render::dump::{DumpDeleteOutcome, DumpOutcome};
18use crate::render::table::render_table_disclosure;
19use crate::render::value::{build_metadata_row, build_value_rows};
20use crate::render::vocabulary::{build_vocabulary_list_rows, build_vocabulary_rows};
21use crate::render::{
22    AUTH_LOGIN_COLUMNS, AUTH_LOGOUT_COLUMNS, DATA_MODEL_DESCRIBE_COLUMNS, DATA_MODEL_STRUCTURE_COLUMNS,
23    DATA_MODELS_COLUMNS, DataModelListView, HeaderMode, MetaContext, PROJECT_DUMP_COLUMNS,
24    PROJECT_DUMP_DELETED_COLUMNS, PROJECTS_COLUMNS, ProjectListView, QuoteMode, RESOURCE_DESCRIBE_COLUMNS,
25    RESOURCE_DESCRIBE_VALUES_COLUMNS, RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS, RESOURCE_LIST_COLUMNS,
26    RESOURCE_TYPE_DESCRIBE_COLUMNS, RESOURCE_TYPES_COLUMNS, Renderer, ResourceListView, ResourceTypeListView,
27    TableOptions, TableSpec, VOCABULARIES_COLUMNS, VOCABULARY_DESCRIBE_COLUMNS, VocabularyListView, render_table,
28};
29use crate::util::text::replace_control_chars;
30
31/// Renders output as one identifier per line.
32pub struct LinesRenderer {
33    out: Box<dyn Write>,
34    /// Auth-state disclosure sink. In production (`new()`), this is
35    /// `io::stderr()`. In tests using `with_writer`, it is `io::sink()` so
36    /// existing tests don't emit to real stderr. Use `with_writers` to capture
37    /// both streams in new tabular-disclosure tests.
38    err: Box<dyn Write>,
39    /// Per-invocation tabular options (column projection).
40    /// Defaults to all columns selected from the lean default subset — matches
41    /// today's unflagged lean behaviour.
42    options: TableOptions,
43}
44
45impl LinesRenderer {
46    /// Creates a renderer writing to stdout (data) and stderr (disclosure).
47    ///
48    /// Stdout is wrapped in `BrokenPipeWriter` so `dsp ... | head` exits 0
49    /// silently instead of surfacing a broken pipe as `Diagnostic::Internal`.
50    pub fn new() -> Self {
51        Self {
52            out: Box::new(crate::util::BrokenPipeWriter::new(io::stdout())),
53            err: Box::new(io::stderr()),
54            options: TableOptions::default(),
55        }
56    }
57
58    /// Creates a renderer writing data to `w`; discards disclosure (stderr
59    /// becomes `io::sink()`). Keeps all existing tabular tests working without
60    /// emitting to real stderr.
61    pub fn with_writer(w: impl Write + 'static) -> Self {
62        Self {
63            out: Box::new(w),
64            err: Box::new(io::sink()),
65            options: TableOptions::default(),
66        }
67    }
68
69    /// Creates a renderer writing data to `out` and disclosure to `err`.
70    /// Used by tests that assert the stderr auth-state line.
71    pub fn with_writers(out: impl Write + 'static, err: impl Write + 'static) -> Self {
72        Self {
73            out: Box::new(out),
74            err: Box::new(err),
75            options: TableOptions::default(),
76        }
77    }
78
79    /// Returns a new renderer with the given tabular options applied.
80    /// Used by `Format::into_renderer_with_options` and tests.
81    pub fn with_options(mut self, opts: TableOptions) -> Self {
82        self.options = opts;
83        self
84    }
85}
86
87impl Default for LinesRenderer {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl Renderer for LinesRenderer {
94    fn diagnostic(&mut self, diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
95        eprintln!("Error: {diag}"); // errors go to stderr for non-JSON formats (dsp-cli/ADR-0012)
96        Ok(())
97    }
98
99    fn auth_login(&mut self, outcome: &AuthLoginOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
100        let expires = outcome.expires_at.map(|dt| dt.to_rfc3339()).unwrap_or_default();
101        let rows = vec![vec![
102            outcome.server.clone(),
103            outcome.user.clone(),
104            expires,
105            "login_success".to_string(),
106        ]];
107        let projected = self.options.projected();
108        let spec = TableSpec {
109            all_columns: AUTH_LOGIN_COLUMNS,
110            rows: &rows,
111            default_columns: Some(&["server"]),
112            projected,
113            quote: QuoteMode::Lines,
114            header: HeaderMode::Off,
115        };
116        render_table(&mut *self.out, &spec)
117    }
118
119    fn auth_status(&mut self, outcome: &AuthStatusOutcome, meta: &MetaContext) -> Result<(), Diagnostic> {
120        let row = match outcome {
121            AuthStatusOutcome::LoggedIn { server, user, expires_at, expired } => {
122                let user_str = user.as_deref().unwrap_or("").to_string();
123                let expires = expires_at.map(|dt| dt.to_rfc3339()).unwrap_or_default();
124                let state = if *expired { "expired" } else { "logged_in" };
125                vec![server.clone(), user_str, expires, state.to_string()]
126            }
127            // DSP_TOKEN env-override: row shape is uniform with LoggedIn (user empty,
128            // expires_at rfc3339 or empty, state "logged_in"|"expired"). The source
129            // is never a stdout column.
130            AuthStatusOutcome::AuthenticatedViaEnv { server, expires_at, expired } => {
131                let expires = expires_at.map(|dt| dt.to_rfc3339()).unwrap_or_default();
132                let state = if *expired { "expired" } else { "logged_in" };
133                vec![server.clone(), String::new(), expires, state.to_string()]
134            }
135            AuthStatusOutcome::NotLoggedIn { server } => {
136                vec![
137                    server.clone(),
138                    String::new(),
139                    String::new(),
140                    "not_logged_in".to_string(),
141                ]
142            }
143        };
144        let rows = vec![row];
145        let projected = self.options.projected();
146        let spec = TableSpec {
147            all_columns: AUTH_LOGIN_COLUMNS,
148            rows: &rows,
149            default_columns: Some(&["server"]),
150            projected,
151            quote: QuoteMode::Lines,
152            header: HeaderMode::Off,
153        };
154        render_table(&mut *self.out, &spec)?;
155        render_table_disclosure(&mut *self.err, meta)?;
156        Ok(())
157    }
158
159    fn auth_logout(&mut self, outcome: &AuthLogoutOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
160        // Sanctioned engine bypass (plan 020 D6): the lines default emits the
161        // decorated literal `<server>\twas_cached=true|false`, which the engine
162        // cannot reproduce.  When --columns is active (projection path), plain
163        // values go through the engine with replace_control_chars applied.
164        //
165        // The server field is passed through replace_control_chars even on the
166        // bespoke path — byte-identical for all normal server URLs (which
167        // contain no ASCII control chars), but correct-by-construction
168        // regardless of what the server string contains.
169        if self.options.columns.is_none() {
170            // Default (decorated) path — bespoke writeln!.
171            writeln!(
172                self.out,
173                "{}\twas_cached={}",
174                replace_control_chars(&outcome.server),
175                outcome.was_cached
176            )?;
177        } else {
178            // Projected path — engine with plain was_cached value ("true"/"false").
179            let rows = vec![vec![outcome.server.clone(), outcome.was_cached.to_string()]];
180            let projected = self.options.projected();
181            let spec = TableSpec {
182                all_columns: AUTH_LOGOUT_COLUMNS,
183                rows: &rows,
184                default_columns: None,
185                projected,
186                quote: QuoteMode::Lines,
187                header: HeaderMode::Off,
188            };
189            render_table(&mut *self.out, &spec)?;
190        }
191        Ok(())
192    }
193
194    fn auth_set_token(&mut self, outcome: &AuthSetTokenOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
195        let user_str = outcome.user.as_deref().unwrap_or("").to_string();
196        let expires = outcome.expires_at.map(|dt| dt.to_rfc3339()).unwrap_or_default();
197        let rows = vec![vec![
198            outcome.server.clone(),
199            user_str,
200            expires,
201            "token_cached".to_string(),
202        ]];
203        let projected = self.options.projected();
204        let spec = TableSpec {
205            all_columns: AUTH_LOGIN_COLUMNS,
206            rows: &rows,
207            default_columns: Some(&["server"]),
208            projected,
209            quote: QuoteMode::Lines,
210            header: HeaderMode::Off,
211        };
212        render_table(&mut *self.out, &spec)
213    }
214
215    fn project_dump(&mut self, outcome: &DumpOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
216        // Lines format: bare path only; cleanup disclosure is omitted (prose/json only).
217        // reused/created_at are also prose/json only.
218        let rows = vec![vec![outcome.path.display().to_string()]];
219        let projected = self.options.projected();
220        let spec = TableSpec {
221            all_columns: PROJECT_DUMP_COLUMNS,
222            rows: &rows,
223            default_columns: Some(&["path"]),
224            projected,
225            quote: QuoteMode::Lines,
226            header: HeaderMode::Off,
227        };
228        render_table(&mut *self.out, &spec)
229    }
230
231    fn project_dump_deleted(&mut self, outcome: &DumpDeleteOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
232        // Lines format: one token — "deleted" when true, "not-deleted" otherwise.
233        // Note: csv builds true/false for the same `deleted` column; lines builds
234        // "deleted"/"not-deleted". Each renderer owns its own row values, so this
235        // divergence is acceptable — it preserves byte-identity for both formats'
236        // existing snapshots.
237        let token = if outcome.deleted {
238            "deleted".to_string()
239        } else {
240            "not-deleted".to_string()
241        };
242        let rows = vec![vec![token]];
243        let projected = self.options.projected();
244        let spec = TableSpec {
245            all_columns: PROJECT_DUMP_DELETED_COLUMNS,
246            rows: &rows,
247            default_columns: Some(&["deleted"]),
248            projected,
249            quote: QuoteMode::Lines,
250            header: HeaderMode::Off,
251        };
252        render_table(&mut *self.out, &spec)
253    }
254
255    fn projects(&mut self, view: &ProjectListView, meta: &MetaContext) -> Result<(), Diagnostic> {
256        // Lines format: shortcode TAB shortname TAB longname per item, no header.
257        // longname None → empty string (yields a trailing tab — locked by fixture).
258        let rows: Vec<Vec<String>> = view
259            .items
260            .iter()
261            .map(|item| {
262                let longname = item.longname.as_deref().unwrap_or("").to_string();
263                let data_models_str = item.data_models.to_string();
264                vec![
265                    item.shortcode.clone(),
266                    item.shortname.clone(),
267                    longname,
268                    data_models_str,
269                    item.iri.clone(),
270                ]
271            })
272            .collect();
273        let projected = self.options.projected();
274        let spec = TableSpec {
275            all_columns: PROJECTS_COLUMNS,
276            rows: &rows,
277            default_columns: Some(&["shortcode", "shortname", "longname"]),
278            projected,
279            quote: QuoteMode::Lines,
280            header: HeaderMode::Off,
281        };
282        render_table(&mut *self.out, &spec)?;
283        render_table_disclosure(&mut *self.err, meta)?;
284        Ok(())
285    }
286
287    fn project_describe(&mut self, project: &ProjectDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
288        // Lines format: single row shortcode TAB shortname TAB longname, no header.
289        // longname None → empty string (trailing tab — mirrors `projects` lines behaviour).
290        let longname = project.longname.as_deref().unwrap_or("").to_string();
291        let data_models_str = project.data_models.len().to_string();
292        let rows = vec![vec![
293            project.shortcode.clone(),
294            project.shortname.clone(),
295            longname,
296            data_models_str,
297            project.iri.clone(),
298        ]];
299        let projected = self.options.projected();
300        let spec = TableSpec {
301            all_columns: PROJECTS_COLUMNS,
302            rows: &rows,
303            default_columns: Some(&["shortcode", "shortname", "longname"]),
304            projected,
305            quote: QuoteMode::Lines,
306            header: HeaderMode::Off,
307        };
308        render_table(&mut *self.out, &spec)?;
309        render_table_disclosure(&mut *self.err, meta)?;
310        Ok(())
311    }
312
313    fn data_models(&mut self, view: &DataModelListView, meta: &MetaContext) -> Result<(), Diagnostic> {
314        // Lines format: name TAB iri per item, no header.
315        // Deliberately omits label/last_modified/is_builtin — lean chaining format.
316        let rows: Vec<Vec<String>> = view
317            .items
318            .iter()
319            .map(|item| {
320                let label = item.label.as_deref().unwrap_or("").to_string();
321                let last_modified = item.last_modified.as_deref().unwrap_or("").to_string();
322                let is_builtin_str = if item.is_builtin { "true" } else { "false" };
323                vec![
324                    item.name.clone(),
325                    item.iri.clone(),
326                    label,
327                    last_modified,
328                    is_builtin_str.to_string(),
329                ]
330            })
331            .collect();
332        let projected = self.options.projected();
333        let spec = TableSpec {
334            all_columns: DATA_MODELS_COLUMNS,
335            rows: &rows,
336            default_columns: Some(&["name", "iri"]),
337            projected,
338            quote: QuoteMode::Lines,
339            header: HeaderMode::Off,
340        };
341        render_table(&mut *self.out, &spec)?;
342        render_table_disclosure(&mut *self.err, meta)?;
343        Ok(())
344    }
345
346    fn data_model_describe(&mut self, detail: &DataModelDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
347        // Lines format: single row name TAB iri, no header.
348        // Resource-types are omitted — lean chaining format (mirrors project_describe lines).
349        let label = detail.label.as_deref().unwrap_or("").to_string();
350        let last_modified = detail.last_modified.as_deref().unwrap_or("").to_string();
351        let resource_types_str = detail.resource_types.len().to_string();
352        let rows = vec![vec![
353            detail.name.clone(),
354            detail.iri.clone(),
355            label,
356            last_modified,
357            resource_types_str,
358        ]];
359        let projected = self.options.projected();
360        let spec = TableSpec {
361            all_columns: DATA_MODEL_DESCRIBE_COLUMNS,
362            rows: &rows,
363            default_columns: Some(&["name", "iri"]),
364            projected,
365            quote: QuoteMode::Lines,
366            header: HeaderMode::Off,
367        };
368        render_table(&mut *self.out, &spec)?;
369        render_table_disclosure(&mut *self.err, meta)?;
370        Ok(())
371    }
372
373    fn resource_types(&mut self, view: &ResourceTypeListView, meta: &MetaContext) -> Result<(), Diagnostic> {
374        // Lines format: name TAB iri per item, no header.
375        // Deliberately omits label/is_builtin/count from the default — lean
376        // chaining format (mirrors data_models). Unlike csv/tsv, the default
377        // stays fixed regardless of `--count` (plan 030); `count` is reachable
378        // via explicit `--columns`.
379        let rows: Vec<Vec<String>> = view
380            .items
381            .iter()
382            .map(|item| {
383                let label = item.label.as_deref().unwrap_or("").to_string();
384                let is_builtin_str = if item.is_builtin { "true" } else { "false" };
385                let count_str = item.count.map(|c| c.to_string()).unwrap_or_default();
386                vec![
387                    item.name.clone(),
388                    item.iri.clone(),
389                    label,
390                    is_builtin_str.to_string(),
391                    count_str,
392                ]
393            })
394            .collect();
395        let projected = self.options.projected();
396        let spec = TableSpec {
397            all_columns: RESOURCE_TYPES_COLUMNS,
398            rows: &rows,
399            default_columns: Some(&["name", "iri"]),
400            projected,
401            quote: QuoteMode::Lines,
402            header: HeaderMode::Off,
403        };
404        render_table(&mut *self.out, &spec)?;
405        render_table_disclosure(&mut *self.err, meta)?;
406        Ok(())
407    }
408
409    fn resource_type_describe(
410        &mut self,
411        detail: &crate::model::ResourceTypeDetail,
412        meta: &MetaContext,
413    ) -> Result<(), Diagnostic> {
414        // Lines format: one row per field — name TAB iri, no header.
415        // Mirrors data_model_describe lines (single identifier rows, lean chaining).
416        //
417        // Uses the shared RESOURCE_TYPE_DESCRIBE_COLUMNS (which now includes `iri`
418        // at position 1), with `default_columns: Some(&["name", "iri"])` for the
419        // lean path. csv/tsv use the same const with their own default_columns that
420        // omit `iri` from the unflagged output (byte-identical to pre-020 csv/tsv).
421        let rows: Vec<Vec<String>> = detail
422            .fields
423            .iter()
424            .map(|field| {
425                let value_type_str = field.value_type.to_string();
426                let link_target = field.link_target.as_deref().unwrap_or("").to_string();
427                let card_str = field.cardinality.to_string();
428                let label = field.label.as_deref().unwrap_or("").to_string();
429                let is_builtin_str = if field.is_builtin { "true" } else { "false" };
430                let data_model = field.data_model.as_deref().unwrap_or("").to_string();
431                vec![
432                    field.name.clone(),
433                    field.iri.clone(),
434                    value_type_str,
435                    link_target,
436                    card_str,
437                    label,
438                    is_builtin_str.to_string(),
439                    data_model,
440                ]
441            })
442            .collect();
443        let projected = self.options.projected();
444        let spec = TableSpec {
445            all_columns: RESOURCE_TYPE_DESCRIBE_COLUMNS,
446            rows: &rows,
447            default_columns: Some(&["name", "iri"]),
448            projected,
449            quote: QuoteMode::Lines,
450            header: HeaderMode::Off,
451        };
452        render_table(&mut *self.out, &spec)?;
453        render_table_disclosure(&mut *self.err, meta)?;
454        Ok(())
455    }
456
457    fn data_model_structure(&mut self, structure: &DataModelStructure, meta: &MetaContext) -> Result<(), Diagnostic> {
458        // Lines format: one relation per line, tab-separated: source TAB target TAB kind TAB field.
459        // field is empty for inherits relations. target_data_model is intentionally omitted
460        // (lean pipe-friendly format — use csv/tsv/json for the full column set). No header.
461        let rows: Vec<Vec<String>> = structure
462            .relations
463            .iter()
464            .map(|rel| {
465                let kind_str = rel.kind.to_string();
466                let field = rel.field.as_deref().unwrap_or("").to_string();
467                let target_dm = rel.target_data_model.as_deref().unwrap_or("").to_string();
468                vec![rel.source.clone(), rel.target.clone(), kind_str, field, target_dm]
469            })
470            .collect();
471        let projected = self.options.projected();
472        let spec = TableSpec {
473            all_columns: DATA_MODEL_STRUCTURE_COLUMNS,
474            rows: &rows,
475            default_columns: Some(&["source", "target", "kind", "field"]),
476            projected,
477            quote: QuoteMode::Lines,
478            header: HeaderMode::Off,
479        };
480        render_table(&mut *self.out, &spec)?;
481        render_table_disclosure(&mut *self.err, meta)?;
482        Ok(())
483    }
484
485    fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
486        let rows: Vec<Vec<String>> = view
487            .items
488            .iter()
489            .map(|item| {
490                vec![
491                    item.label.clone(),
492                    item.iri.clone(),
493                    item.ark_url.as_deref().unwrap_or("").to_string(),
494                    item.creation_date.as_deref().unwrap_or("").to_string(),
495                    item.last_modified.as_deref().unwrap_or("").to_string(),
496                    item.resource_type.clone(),
497                ]
498            })
499            .collect();
500        let projected = self.options.projected();
501        let spec = TableSpec {
502            all_columns: RESOURCE_LIST_COLUMNS,
503            rows: &rows,
504            default_columns: None,
505            projected,
506            quote: QuoteMode::Lines,
507            header: HeaderMode::Off,
508        };
509        render_table(&mut *self.out, &spec)?;
510        render_table_disclosure(&mut *self.err, meta)?;
511        Ok(())
512    }
513
514    fn resource_describe(
515        &mut self,
516        detail: &crate::model::ResourceDetail,
517        meta: &MetaContext,
518    ) -> Result<(), Diagnostic> {
519        // Lines format: `detail.values` selects the shape (dsp-cli/ADR-0013 D1).
520        // None → the metadata row (single row, label TAB iri lean default), unchanged.
521        // Some(fields) → one row per value (long-format), metadata row dropped.
522        match &detail.values {
523            None => {
524                let rows = vec![build_metadata_row(detail)];
525                let projected = self.options.projected();
526                let spec = TableSpec {
527                    all_columns: RESOURCE_DESCRIBE_COLUMNS,
528                    rows: &rows,
529                    default_columns: Some(&["label", "iri"]),
530                    projected,
531                    quote: QuoteMode::Lines,
532                    header: HeaderMode::Off,
533                };
534                render_table(&mut *self.out, &spec)?;
535            }
536            Some(fields) => {
537                let rows = build_value_rows(&detail.label, &detail.iri, fields);
538                let projected = self.options.projected();
539                let spec = TableSpec {
540                    all_columns: RESOURCE_DESCRIBE_VALUES_COLUMNS,
541                    rows: &rows,
542                    default_columns: Some(RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS),
543                    projected,
544                    quote: QuoteMode::Lines,
545                    header: HeaderMode::Off,
546                };
547                render_table(&mut *self.out, &spec)?;
548            }
549        }
550        render_table_disclosure(&mut *self.err, meta)?;
551        Ok(())
552    }
553
554    fn vocabularies(&mut self, view: &VocabularyListView, meta: &MetaContext) -> Result<(), Diagnostic> {
555        // Lines format: name TAB iri per item, no header — lean chaining
556        // format, language-neutral (D4 forbids picking a language even for
557        // the default). `nodes`/`depth` are reachable via `--columns`.
558        let rows = build_vocabulary_list_rows(&view.items);
559        let projected = self.options.projected();
560        let spec = TableSpec {
561            all_columns: VOCABULARIES_COLUMNS,
562            rows: &rows,
563            default_columns: Some(&["name", "iri"]),
564            projected,
565            quote: QuoteMode::Lines,
566            header: HeaderMode::Off,
567        };
568        render_table(&mut *self.out, &spec)?;
569        render_table_disclosure(&mut *self.err, meta)?;
570        Ok(())
571    }
572
573    fn vocabulary_describe(&mut self, detail: &VocabularyDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
574        // Lines format: one row per node — node_iri TAB number, no header.
575        // `number` (language-neutral, D4) is the one column that makes the
576        // hierarchy legible in a flat format.
577        let rows = build_vocabulary_rows(detail);
578        let projected = self.options.projected();
579        let spec = TableSpec {
580            all_columns: VOCABULARY_DESCRIBE_COLUMNS,
581            rows: &rows,
582            default_columns: Some(&["node_iri", "number"]),
583            projected,
584            quote: QuoteMode::Lines,
585            header: HeaderMode::Off,
586        };
587        render_table(&mut *self.out, &spec)?;
588        render_table_disclosure(&mut *self.err, meta)?;
589        Ok(())
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596    use crate::model::{DataModel, DataModelDetail, Project, ProjectDetail, ResourceType, ResourceTypeSummary};
597    use crate::render::test_support::{SharedBuf, make_meta};
598    use crate::render::{DataModelListView, ResourceTypeListView};
599
600    fn make_fixture() -> Vec<Project> {
601        vec![
602            Project {
603                iri: "http://rdfh.ch/projects/0001".into(),
604                shortcode: "0001".into(),
605                shortname: "anything".into(),
606                longname: Some("Anything Project".into()),
607                data_models: 2,
608            },
609            Project {
610                iri: "http://rdfh.ch/projects/0002".into(),
611                shortcode: "0002".into(),
612                shortname: "images".into(),
613                longname: None,
614                data_models: 0,
615            },
616            Project {
617                iri: "http://rdfh.ch/projects/0803".into(),
618                shortcode: "0803".into(),
619                shortname: "daschland".into(),
620                longname: Some("DaSCHland Project".into()),
621                data_models: 1,
622            },
623        ]
624    }
625
626    #[test]
627    fn projects_lines_output() {
628        let out = SharedBuf::new();
629        let err = SharedBuf::new();
630        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
631        let view = ProjectListView { items: make_fixture(), total: 3, filter: None };
632        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
633        renderer.projects(&view, &meta).unwrap();
634
635        let stdout = out.string();
636        // No header
637        assert!(!stdout.starts_with("shortcode"));
638        // Three rows: shortcode TAB shortname TAB longname
639        assert!(stdout.contains("0001\tanything\tAnything Project\n"));
640        // None longname → trailing tab (empty third field)
641        assert!(stdout.contains("0002\timages\t\n"));
642        assert!(stdout.contains("0803\tdaschland\tDaSCHland Project\n"));
643    }
644
645    #[test]
646    fn projects_lines_stderr_disclosure() {
647        let out = SharedBuf::new();
648        let err = SharedBuf::new();
649        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
650        let view = ProjectListView { items: make_fixture(), total: 3, filter: None };
651        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
652        renderer.projects(&view, &meta).unwrap();
653
654        let err_str = err.string();
655        assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
656        assert!(!out.string().contains("[anonymous"));
657    }
658
659    #[test]
660    fn auth_status_lines_stderr_disclosure() {
661        // AuthenticatedViaEnv — the env-vs-cache gap this fix closes
662        let out = SharedBuf::new();
663        let err = SharedBuf::new();
664        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
665        let meta = make_meta("authenticated via DSP_TOKEN", "https://api.prod.dasch.swiss");
666        renderer
667            .auth_status(
668                &AuthStatusOutcome::AuthenticatedViaEnv {
669                    server: "https://api.prod.dasch.swiss".to_string(),
670                    expires_at: None,
671                    expired: false,
672                },
673                &meta,
674            )
675            .unwrap();
676        let err_str = err.string();
677        assert_eq!(err_str.trim(), "[authenticated via DSP_TOKEN on https://api.prod.dasch.swiss]");
678        // Disclosure must not appear on stdout
679        assert!(!out.string().contains('['));
680
681        // NotLoggedIn — disclosure emitted for every outcome
682        let out2 = SharedBuf::new();
683        let err2 = SharedBuf::new();
684        let mut renderer2 = LinesRenderer::with_writers(out2.clone(), err2.clone());
685        let meta2 = make_meta("anonymous", "https://api.prod.dasch.swiss");
686        renderer2
687            .auth_status(
688                &AuthStatusOutcome::NotLoggedIn { server: "https://api.prod.dasch.swiss".to_string() },
689                &meta2,
690            )
691            .unwrap();
692        let err2_str = err2.string();
693        assert_eq!(err2_str.trim(), "[anonymous on https://api.prod.dasch.swiss]");
694        // Disclosure must not appear on stdout (symmetry with the env case above)
695        assert!(!out2.string().contains('['));
696    }
697
698    #[test]
699    fn projects_lines_with_writer_no_stderr_leak() {
700        let out = SharedBuf::new();
701        let mut renderer = LinesRenderer::with_writer(out.clone());
702        let view = ProjectListView { items: vec![], total: 0, filter: None };
703        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
704        renderer.projects(&view, &meta).unwrap();
705        // Empty list → no data rows; disclosure discarded to sink
706        assert_eq!(out.string(), "");
707    }
708
709    #[test]
710    fn project_describe_lines_output() {
711        let out = SharedBuf::new();
712        let err = SharedBuf::new();
713        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
714        let detail = ProjectDetail {
715            iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
716            shortcode: "0801".into(),
717            shortname: "beol".into(),
718            longname: Some("Bernoulli-Euler Online".into()),
719            description: vec![],
720            keywords: vec![],
721            data_models: vec![],
722        };
723        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
724        renderer.project_describe(&detail, &meta).unwrap();
725
726        let stdout = out.string();
727        // Single row: shortcode TAB shortname TAB longname
728        assert!(stdout.contains("0801\tbeol\tBernoulli-Euler Online\n"));
729        // No header
730        assert!(!stdout.starts_with("shortcode"));
731    }
732
733    #[test]
734    fn project_describe_lines_no_longname_trailing_tab() {
735        let out = SharedBuf::new();
736        let err = SharedBuf::new();
737        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
738        let detail = ProjectDetail {
739            iri: "http://rdfh.ch/projects/0000".into(),
740            shortcode: "0000".into(),
741            shortname: "minimal".into(),
742            longname: None,
743            description: vec![],
744            keywords: vec![],
745            data_models: vec![],
746        };
747        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
748        renderer.project_describe(&detail, &meta).unwrap();
749
750        let stdout = out.string();
751        // None longname → trailing tab (empty third field)
752        assert!(stdout.contains("0000\tminimal\t\n"));
753    }
754
755    #[test]
756    fn project_describe_lines_stderr_disclosure() {
757        let out = SharedBuf::new();
758        let err = SharedBuf::new();
759        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
760        let detail = ProjectDetail {
761            iri: "http://rdfh.ch/projects/0001".into(),
762            shortcode: "0001".into(),
763            shortname: "test".into(),
764            longname: None,
765            description: vec![],
766            keywords: vec![],
767            data_models: vec![],
768        };
769        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
770        renderer.project_describe(&detail, &meta).unwrap();
771
772        let err_str = err.string();
773        assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
774        // Disclosure must not appear on stdout
775        assert!(!out.string().contains("[anonymous"));
776    }
777
778    fn make_data_model_fixture() -> Vec<DataModel> {
779        vec![
780            DataModel {
781                name: "beol".into(),
782                iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
783                label: Some("The BEOL data-model".into()),
784                last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
785                is_builtin: false,
786            },
787            DataModel {
788                name: "biblio".into(),
789                iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
790                label: None,
791                last_modified: None,
792                is_builtin: false,
793            },
794            DataModel {
795                name: "knora-api".into(),
796                iri: "http://api.knora.org/ontology/knora-api/v2".into(),
797                label: None,
798                last_modified: None,
799                is_builtin: true,
800            },
801        ]
802    }
803
804    #[test]
805    fn data_models_lines_output() {
806        let out = SharedBuf::new();
807        let err = SharedBuf::new();
808        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
809        let view = DataModelListView { items: make_data_model_fixture(), total: 3, filter: None };
810        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
811        renderer.data_models(&view, &meta).unwrap();
812
813        let stdout = out.string();
814        // No header
815        assert!(!stdout.starts_with("name"));
816        // name TAB iri rows
817        assert!(stdout.contains("beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\n"));
818        assert!(stdout.contains("biblio\thttp://api.dasch.swiss/ontology/0801/biblio/v2\n"));
819        assert!(stdout.contains("knora-api\thttp://api.knora.org/ontology/knora-api/v2\n"));
820        // label/last_modified/is_builtin are NOT in lines output
821        assert!(!stdout.contains("The BEOL"));
822        assert!(!stdout.contains("2024-05-27"));
823        assert!(!stdout.contains("true"));
824    }
825
826    #[test]
827    fn data_models_lines_stderr_disclosure() {
828        let out = SharedBuf::new();
829        let err = SharedBuf::new();
830        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
831        let view = DataModelListView { items: make_data_model_fixture(), total: 3, filter: None };
832        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
833        renderer.data_models(&view, &meta).unwrap();
834
835        let err_str = err.string();
836        assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
837        // Disclosure must not appear on stdout
838        assert!(!out.string().contains("[anonymous"));
839    }
840
841    // ── data_model_describe lines tests ──────────────────────────────────────
842
843    fn make_beol_dm_detail() -> DataModelDetail {
844        DataModelDetail {
845            name: "beol".into(),
846            iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
847            label: Some("The BEOL data-model".into()),
848            last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
849            resource_types: vec![
850                ResourceTypeSummary {
851                    name: "Archive".into(),
852                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
853                    label: Some("Archive".into()),
854                },
855                ResourceTypeSummary {
856                    name: "basicLetter".into(),
857                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
858                    label: None,
859                },
860                ResourceTypeSummary {
861                    name: "letter".into(),
862                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
863                    label: Some("Letter".into()),
864                },
865            ],
866        }
867    }
868
869    #[test]
870    fn data_model_describe_lines_output() {
871        let out = SharedBuf::new();
872        let err = SharedBuf::new();
873        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
874        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
875        renderer.data_model_describe(&make_beol_dm_detail(), &meta).unwrap();
876
877        let stdout = out.string();
878        // Single row: name TAB iri
879        assert!(
880            stdout.contains("beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\n"),
881            "expected name TAB iri row; got:\n{stdout}"
882        );
883        // No header
884        assert!(!stdout.starts_with("name"));
885        // resource-types must NOT appear in lines output
886        assert!(!stdout.contains("Archive"), "resource-types must be omitted");
887    }
888
889    #[test]
890    fn data_model_describe_lines_stderr_disclosure() {
891        let out = SharedBuf::new();
892        let err = SharedBuf::new();
893        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
894        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
895        renderer.data_model_describe(&make_beol_dm_detail(), &meta).unwrap();
896
897        let err_str = err.string();
898        assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
899        // Disclosure must not appear on stdout
900        assert!(!out.string().contains("[anonymous"));
901    }
902
903    // ── resource_types lines tests ────────────────────────────────────────────
904
905    fn make_resource_type_fixture() -> Vec<ResourceType> {
906        vec![
907            ResourceType {
908                name: "Archive".into(),
909                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
910                label: Some("Archive".into()),
911                is_builtin: false,
912                count: None,
913            },
914            ResourceType {
915                name: "letter".into(),
916                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
917                label: Some("Letter".into()),
918                is_builtin: false,
919                count: None,
920            },
921        ]
922    }
923
924    #[test]
925    fn resource_types_lines_output() {
926        let out = SharedBuf::new();
927        let err = SharedBuf::new();
928        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
929        let view = ResourceTypeListView {
930            items: make_resource_type_fixture(),
931            total: 2,
932            filter: None,
933            data_model: "beol".into(),
934        };
935        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
936        renderer.resource_types(&view, &meta).unwrap();
937
938        let stdout = out.string();
939        // No header
940        assert!(!stdout.starts_with("name"));
941        // name TAB iri rows
942        assert!(stdout.contains("Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\n"));
943        assert!(stdout.contains("letter\thttp://api.dasch.swiss/ontology/0801/beol/v2#letter\n"));
944        // label/is_builtin NOT in lines output
945        assert!(!stdout.contains("Letter"));
946        assert!(!stdout.contains("true"));
947        assert!(!stdout.contains("false"));
948    }
949
950    #[test]
951    fn resource_types_lines_with_builtins() {
952        let out = SharedBuf::new();
953        let err = SharedBuf::new();
954        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
955        let mut items = make_resource_type_fixture();
956        items.push(ResourceType {
957            name: "Region".into(),
958            iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
959            label: Some("Region".into()),
960            is_builtin: true,
961            count: None,
962        });
963        let view = ResourceTypeListView { items, total: 3, filter: None, data_model: "beol".into() };
964        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
965        renderer.resource_types(&view, &meta).unwrap();
966
967        let stdout = out.string();
968        // built-in appears as a plain name TAB iri row
969        assert!(stdout.contains("Region\thttp://api.knora.org/ontology/knora-api/v2#Region\n"));
970        // no is_builtin column
971        assert!(!stdout.contains("true"));
972    }
973
974    #[test]
975    fn resource_types_lines_stderr_disclosure() {
976        let out = SharedBuf::new();
977        let err = SharedBuf::new();
978        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
979        let view = ResourceTypeListView {
980            items: make_resource_type_fixture(),
981            total: 2,
982            filter: None,
983            data_model: "beol".into(),
984        };
985        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
986        renderer.resource_types(&view, &meta).unwrap();
987
988        let err_str = err.string();
989        assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
990        assert!(!out.string().contains("[anonymous"));
991    }
992
993    #[test]
994    fn resource_types_lines_empty() {
995        let out = SharedBuf::new();
996        let err = SharedBuf::new();
997        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
998        let view = ResourceTypeListView {
999            items: vec![],
1000            total: 0,
1001            filter: None,
1002            data_model: "beol".into(),
1003        };
1004        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1005        renderer.resource_types(&view, &meta).unwrap();
1006
1007        // No data rows; disclosure still emitted to stderr
1008        assert_eq!(out.string(), "");
1009        assert!(err.string().contains("[anonymous on https://api.test.dasch.swiss]"));
1010    }
1011
1012    #[test]
1013    fn resource_types_lines_default_unchanged_count_only_via_columns() {
1014        // plan 030: lines' default stays fixed at ["name", "iri"] regardless
1015        // of --count (unlike csv/tsv, which auto-show). `count` is only
1016        // reachable via explicit --columns.
1017        let out = SharedBuf::new();
1018        let err = SharedBuf::new();
1019        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1020        let mut items = make_resource_type_fixture();
1021        items[0].count = Some(10);
1022        let view = ResourceTypeListView { items, total: 2, filter: None, data_model: "beol".into() };
1023        let meta = crate::render::MetaContext {
1024            server_label: "https://api.test.dasch.swiss".into(),
1025            auth_state: "anonymous".into(),
1026            filter_warning: None,
1027            count_caveat: Some("counts are not permission-filtered".into()),
1028            count_cost: None,
1029        };
1030        renderer.resource_types(&view, &meta).unwrap();
1031
1032        let stdout = out.string();
1033        // Default output stays name TAB iri only — no count cell leaks in.
1034        assert!(stdout.contains("Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\n"));
1035        assert!(
1036            !stdout.contains("10"),
1037            "count must not leak into default output; got:\n{stdout}"
1038        );
1039        assert!(
1040            err.string().contains("counts are not permission-filtered"),
1041            "stderr must carry count_caveat; got: {:?}",
1042            err.string()
1043        );
1044    }
1045
1046    #[test]
1047    fn resource_types_lines_columns_count_explicit_projection() {
1048        let out = SharedBuf::new();
1049        let mut renderer = LinesRenderer::with_writer(out.clone()).with_options(TableOptions {
1050            columns: Some(vec!["name".to_string(), "count".to_string()]),
1051            header: HeaderMode::Off,
1052        });
1053        let mut items = make_resource_type_fixture();
1054        items[0].count = Some(10);
1055        let view = ResourceTypeListView { items, total: 2, filter: None, data_model: "beol".into() };
1056        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1057        renderer.resource_types(&view, &meta).unwrap();
1058
1059        let stdout = out.string();
1060        assert!(stdout.contains("Archive\t10\n"), "got:\n{stdout}");
1061        // letter has no count -> empty cell after the tab.
1062        assert!(stdout.contains("letter\t\n"), "got:\n{stdout}");
1063    }
1064
1065    #[test]
1066    fn resource_type_describe_lines_columns_no_count_column_exists() {
1067        // Part G scope note: resource_type_describe is untouched by this
1068        // step — `count` (a resource-type-level field) has no column in this
1069        // row-per-field table. `--columns count` must be a usage error.
1070        let out = SharedBuf::new();
1071        let mut renderer = LinesRenderer::with_writer(out.clone()).with_options(TableOptions {
1072            columns: Some(vec!["count".to_string()]),
1073            header: HeaderMode::Off,
1074        });
1075        let detail = crate::model::ResourceTypeDetail {
1076            name: "manuscript".into(),
1077            iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1078            label: Some("Manuscript".into()),
1079            data_model: "beol".into(),
1080            representation: None,
1081            super_types: vec![],
1082            fields: vec![],
1083            count: Some(7),
1084        };
1085        let meta = make_meta("anonymous", "https://api.dasch.swiss");
1086        let result = renderer.resource_type_describe(&detail, &meta);
1087        assert!(result.is_err(), "count is not a valid column for resource_type_describe");
1088    }
1089
1090    // ── auth_logout lines tests (D6 bypass + projected path) ─────────────────
1091
1092    #[test]
1093    fn auth_logout_lines_default_decorated_output() {
1094        // Default path: bespoke writeln! produces <server>\twas_cached=<bool>.
1095        let out = SharedBuf::new();
1096        let mut renderer = LinesRenderer::with_writer(out.clone());
1097        renderer
1098            .auth_logout(
1099                &AuthLogoutOutcome {
1100                    server: "https://api.example.com".to_string(),
1101                    was_cached: true,
1102                },
1103                &make_meta("anonymous", "https://api.example.com"),
1104            )
1105            .unwrap();
1106        assert_eq!(out.string(), "https://api.example.com\twas_cached=true\n");
1107    }
1108
1109    #[test]
1110    fn auth_logout_lines_projected_was_cached_plain_value() {
1111        // Projected path via engine: --columns was_cached → "true\n" (plain bool string,
1112        // no "was_cached=" prefix). Pins plan 020 D6's projected path contract.
1113        let out = SharedBuf::new();
1114        let mut renderer = LinesRenderer::with_writer(out.clone()).with_options(TableOptions {
1115            columns: Some(vec!["was_cached".to_string()]),
1116            header: HeaderMode::Off,
1117        });
1118        renderer
1119            .auth_logout(
1120                &AuthLogoutOutcome {
1121                    server: "https://api.example.com".to_string(),
1122                    was_cached: true,
1123                },
1124                &make_meta("anonymous", "https://api.example.com"),
1125            )
1126            .unwrap();
1127        assert_eq!(out.string(), "true\n");
1128    }
1129
1130    // ── resource_describe lines values-note tests ─────────────────────────────
1131
1132    use crate::model::{FieldValues, ResourceAccess, ResourceDetail, ResourceVisibility, ValueContent};
1133
1134    fn make_resource_detail(values: Option<Vec<FieldValues>>) -> ResourceDetail {
1135        ResourceDetail {
1136            label: "Test Resource".into(),
1137            iri: "http://rdfh.ch/0803/abc123".into(),
1138            resource_type: "Page".into(),
1139            ark_url: None,
1140            creation_date: None,
1141            last_modified: None,
1142            attached_project: None,
1143            owner: None,
1144            visibility: Some(ResourceVisibility::Public),
1145            your_access: Some(ResourceAccess::View),
1146            values,
1147        }
1148    }
1149
1150    #[test]
1151    fn resource_describe_lines_no_values_no_note() {
1152        // When values is None, no stderr note is emitted; the metadata row renders
1153        // unchanged.
1154        let out = SharedBuf::new();
1155        let err = SharedBuf::new();
1156        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1157        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1158        renderer.resource_describe(&make_resource_detail(None), &meta).unwrap();
1159
1160        let err_str = err.string();
1161        assert!(
1162            !err_str.contains("values not shown"),
1163            "no values note must be emitted when values is None; got stderr:\n{err_str}"
1164        );
1165        // Metadata row still present on stdout
1166        assert!(
1167            out.string().contains("Test Resource"),
1168            "metadata row missing; got:\n{}",
1169            out.string()
1170        );
1171    }
1172
1173    #[test]
1174    fn resource_describe_lines_some_values_no_note_renders_value_rows() {
1175        // When values is Some, no stderr note is emitted; stdout carries one row
1176        // per value in the compact default columns (field, field_label, value_type,
1177        // value) — no label/iri (opt-in only).
1178        let out = SharedBuf::new();
1179        let err = SharedBuf::new();
1180        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1181        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1182        renderer
1183            .resource_describe(
1184                &make_resource_detail(Some(vec![FieldValues {
1185                    name: "hasTitle".into(),
1186                    label: None,
1187                    values: vec![ValueContent::Text("Hello".into()).into()],
1188                }])),
1189                &meta,
1190            )
1191            .unwrap();
1192
1193        let err_str = err.string();
1194        assert!(
1195            !err_str.contains("values not shown"),
1196            "no values note must be emitted when values is Some; got stderr:\n{err_str}"
1197        );
1198        let stdout = out.string();
1199        assert!(
1200            stdout.contains("hasTitle\t\ttext\tHello\n"),
1201            "expected default-columns value row field\\tfield_label\\tvalue_type\\tvalue; got:\n{stdout}"
1202        );
1203        // Metadata (label/iri) is dropped in values mode by default.
1204        assert!(
1205            !stdout.contains("Test Resource"),
1206            "metadata row must not appear in values mode; got:\n{stdout}"
1207        );
1208    }
1209
1210    #[test]
1211    fn resource_describe_lines_columns_comment_renders_comment_column() {
1212        // comment is opt-in via --columns; selecting it must surface the
1213        // per-value comment text in the projected column position, and the
1214        // default-columns test above must stay unaffected (comment absent).
1215        use crate::model::Value;
1216
1217        let out = SharedBuf::new();
1218        let err = SharedBuf::new();
1219        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone()).with_options(TableOptions {
1220            columns: Some(vec!["field".to_string(), "value".to_string(), "comment".to_string()]),
1221            header: HeaderMode::Off,
1222        });
1223        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1224        renderer
1225            .resource_describe(
1226                &make_resource_detail(Some(vec![FieldValues {
1227                    name: "hasTranscription".into(),
1228                    label: None,
1229                    values: vec![Value {
1230                        content: ValueContent::Text("teh book".into()),
1231                        comment: Some("reading uncertain".into()),
1232                    }],
1233                }])),
1234                &meta,
1235            )
1236            .unwrap();
1237
1238        let stdout = out.string();
1239        assert!(
1240            stdout.contains("hasTranscription\tteh book\treading uncertain\n"),
1241            "expected projected field/value/comment row; got:\n{stdout}"
1242        );
1243    }
1244
1245    #[test]
1246    fn resource_describe_lines_default_columns_omits_comment_even_when_present() {
1247        // comment is opt-in via --columns, NOT part of the default column set —
1248        // this must hold even when the underlying value DOES carry a comment
1249        // (distinct from `resource_describe_lines_some_values_no_note_renders_value_rows`,
1250        // whose fixture has no comment at all and so cannot demonstrate this).
1251        use crate::model::Value;
1252
1253        let out = SharedBuf::new();
1254        let err = SharedBuf::new();
1255        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1256        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1257        renderer
1258            .resource_describe(
1259                &make_resource_detail(Some(vec![FieldValues {
1260                    name: "hasTranscription".into(),
1261                    label: None,
1262                    values: vec![Value {
1263                        content: ValueContent::Text("teh book".into()),
1264                        comment: Some("reading uncertain".into()),
1265                    }],
1266                }])),
1267                &meta,
1268            )
1269            .unwrap();
1270
1271        let stdout = out.string();
1272        assert!(
1273            stdout.contains("hasTranscription\t\ttext\tteh book\n"),
1274            "expected default-columns row field\\tfield_label\\tvalue_type\\tvalue \
1275             (no comment column); got:\n{stdout}"
1276        );
1277        assert!(
1278            !stdout.contains("reading uncertain"),
1279            "comment must NOT appear under default columns even though the value \
1280             has one — comment is opt-in via --columns, not in the default set; \
1281             got:\n{stdout}"
1282        );
1283    }
1284}