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                    item.status.as_str().to_string(),
269                    data_models_str,
270                    item.iri.clone(),
271                ]
272            })
273            .collect();
274        let projected = self.options.projected();
275        let spec = TableSpec {
276            all_columns: PROJECTS_COLUMNS,
277            rows: &rows,
278            default_columns: Some(&["shortcode", "shortname", "longname"]),
279            projected,
280            quote: QuoteMode::Lines,
281            header: HeaderMode::Off,
282        };
283        render_table(&mut *self.out, &spec)?;
284        render_table_disclosure(&mut *self.err, meta)?;
285        Ok(())
286    }
287
288    fn project_describe(&mut self, project: &ProjectDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
289        // Lines format: single row shortcode TAB shortname TAB longname, no header.
290        // longname None → empty string (trailing tab — mirrors `projects` lines behaviour).
291        let longname = project.longname.as_deref().unwrap_or("").to_string();
292        let data_models_str = project.data_models.len().to_string();
293        let rows = vec![vec![
294            project.shortcode.clone(),
295            project.shortname.clone(),
296            longname,
297            project.status.as_str().to_string(),
298            data_models_str,
299            project.iri.clone(),
300        ]];
301        let projected = self.options.projected();
302        let spec = TableSpec {
303            all_columns: PROJECTS_COLUMNS,
304            rows: &rows,
305            default_columns: Some(&["shortcode", "shortname", "longname"]),
306            projected,
307            quote: QuoteMode::Lines,
308            header: HeaderMode::Off,
309        };
310        render_table(&mut *self.out, &spec)?;
311        render_table_disclosure(&mut *self.err, meta)?;
312        Ok(())
313    }
314
315    fn data_models(&mut self, view: &DataModelListView, meta: &MetaContext) -> Result<(), Diagnostic> {
316        // Lines format: name TAB iri per item, no header.
317        // Deliberately omits label/last_modified/is_builtin — lean chaining format.
318        let rows: Vec<Vec<String>> = view
319            .items
320            .iter()
321            .map(|item| {
322                let label = item.label.as_deref().unwrap_or("").to_string();
323                let last_modified = item.last_modified.as_deref().unwrap_or("").to_string();
324                let is_builtin_str = if item.is_builtin { "true" } else { "false" };
325                vec![
326                    item.name.clone(),
327                    item.iri.clone(),
328                    label,
329                    last_modified,
330                    is_builtin_str.to_string(),
331                ]
332            })
333            .collect();
334        let projected = self.options.projected();
335        let spec = TableSpec {
336            all_columns: DATA_MODELS_COLUMNS,
337            rows: &rows,
338            default_columns: Some(&["name", "iri"]),
339            projected,
340            quote: QuoteMode::Lines,
341            header: HeaderMode::Off,
342        };
343        render_table(&mut *self.out, &spec)?;
344        render_table_disclosure(&mut *self.err, meta)?;
345        Ok(())
346    }
347
348    fn data_model_describe(&mut self, detail: &DataModelDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
349        // Lines format: single row name TAB iri, no header.
350        // Resource-types are omitted — lean chaining format (mirrors project_describe lines).
351        let label = detail.label.as_deref().unwrap_or("").to_string();
352        let last_modified = detail.last_modified.as_deref().unwrap_or("").to_string();
353        let resource_types_str = detail.resource_types.len().to_string();
354        let rows = vec![vec![
355            detail.name.clone(),
356            detail.iri.clone(),
357            label,
358            last_modified,
359            resource_types_str,
360        ]];
361        let projected = self.options.projected();
362        let spec = TableSpec {
363            all_columns: DATA_MODEL_DESCRIBE_COLUMNS,
364            rows: &rows,
365            default_columns: Some(&["name", "iri"]),
366            projected,
367            quote: QuoteMode::Lines,
368            header: HeaderMode::Off,
369        };
370        render_table(&mut *self.out, &spec)?;
371        render_table_disclosure(&mut *self.err, meta)?;
372        Ok(())
373    }
374
375    fn resource_types(&mut self, view: &ResourceTypeListView, meta: &MetaContext) -> Result<(), Diagnostic> {
376        // Lines format: name TAB iri per item, no header.
377        // Deliberately omits label/is_builtin/count from the default — lean
378        // chaining format (mirrors data_models). Unlike csv/tsv, the default
379        // stays fixed regardless of `--count` (plan 030); `count` is reachable
380        // via explicit `--columns`.
381        let rows: Vec<Vec<String>> = view
382            .items
383            .iter()
384            .map(|item| {
385                let label = item.label.as_deref().unwrap_or("").to_string();
386                let is_builtin_str = if item.is_builtin { "true" } else { "false" };
387                let count_str = item.count.map(|c| c.to_string()).unwrap_or_default();
388                vec![
389                    item.name.clone(),
390                    item.iri.clone(),
391                    label,
392                    is_builtin_str.to_string(),
393                    count_str,
394                ]
395            })
396            .collect();
397        let projected = self.options.projected();
398        let spec = TableSpec {
399            all_columns: RESOURCE_TYPES_COLUMNS,
400            rows: &rows,
401            default_columns: Some(&["name", "iri"]),
402            projected,
403            quote: QuoteMode::Lines,
404            header: HeaderMode::Off,
405        };
406        render_table(&mut *self.out, &spec)?;
407        render_table_disclosure(&mut *self.err, meta)?;
408        Ok(())
409    }
410
411    fn resource_type_describe(
412        &mut self,
413        detail: &crate::model::ResourceTypeDetail,
414        meta: &MetaContext,
415    ) -> Result<(), Diagnostic> {
416        // Lines format: one row per field — name TAB iri, no header.
417        // Mirrors data_model_describe lines (single identifier rows, lean chaining).
418        //
419        // Uses the shared RESOURCE_TYPE_DESCRIBE_COLUMNS (which now includes `iri`
420        // at position 1), with `default_columns: Some(&["name", "iri"])` for the
421        // lean path. csv/tsv use the same const with their own default_columns that
422        // omit `iri` from the unflagged output (byte-identical to pre-020 csv/tsv).
423        let rows: Vec<Vec<String>> = detail
424            .fields
425            .iter()
426            .map(|field| {
427                let value_type_str = field.value_type.to_string();
428                let link_target = field.link_target.as_deref().unwrap_or("").to_string();
429                let card_str = field.cardinality.to_string();
430                let label = field.label.as_deref().unwrap_or("").to_string();
431                let is_builtin_str = if field.is_builtin { "true" } else { "false" };
432                let data_model = field.data_model.as_deref().unwrap_or("").to_string();
433                vec![
434                    field.name.clone(),
435                    field.iri.clone(),
436                    value_type_str,
437                    link_target,
438                    card_str,
439                    label,
440                    is_builtin_str.to_string(),
441                    data_model,
442                ]
443            })
444            .collect();
445        let projected = self.options.projected();
446        let spec = TableSpec {
447            all_columns: RESOURCE_TYPE_DESCRIBE_COLUMNS,
448            rows: &rows,
449            default_columns: Some(&["name", "iri"]),
450            projected,
451            quote: QuoteMode::Lines,
452            header: HeaderMode::Off,
453        };
454        render_table(&mut *self.out, &spec)?;
455        render_table_disclosure(&mut *self.err, meta)?;
456        Ok(())
457    }
458
459    fn data_model_structure(&mut self, structure: &DataModelStructure, meta: &MetaContext) -> Result<(), Diagnostic> {
460        // Lines format: one relation per line, tab-separated: source TAB target TAB kind TAB field.
461        // field is empty for inherits relations. target_data_model is intentionally omitted
462        // (lean pipe-friendly format — use csv/tsv/json for the full column set). No header.
463        let rows: Vec<Vec<String>> = structure
464            .relations
465            .iter()
466            .map(|rel| {
467                let kind_str = rel.kind.to_string();
468                let field = rel.field.as_deref().unwrap_or("").to_string();
469                let target_dm = rel.target_data_model.as_deref().unwrap_or("").to_string();
470                vec![rel.source.clone(), rel.target.clone(), kind_str, field, target_dm]
471            })
472            .collect();
473        let projected = self.options.projected();
474        let spec = TableSpec {
475            all_columns: DATA_MODEL_STRUCTURE_COLUMNS,
476            rows: &rows,
477            default_columns: Some(&["source", "target", "kind", "field"]),
478            projected,
479            quote: QuoteMode::Lines,
480            header: HeaderMode::Off,
481        };
482        render_table(&mut *self.out, &spec)?;
483        render_table_disclosure(&mut *self.err, meta)?;
484        Ok(())
485    }
486
487    fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
488        let rows: Vec<Vec<String>> = view
489            .items
490            .iter()
491            .map(|item| {
492                vec![
493                    item.label.clone(),
494                    item.iri.clone(),
495                    item.ark_url.as_deref().unwrap_or("").to_string(),
496                    item.creation_date.as_deref().unwrap_or("").to_string(),
497                    item.last_modified.as_deref().unwrap_or("").to_string(),
498                    item.resource_type.clone(),
499                ]
500            })
501            .collect();
502        let projected = self.options.projected();
503        let spec = TableSpec {
504            all_columns: RESOURCE_LIST_COLUMNS,
505            rows: &rows,
506            default_columns: None,
507            projected,
508            quote: QuoteMode::Lines,
509            header: HeaderMode::Off,
510        };
511        render_table(&mut *self.out, &spec)?;
512        render_table_disclosure(&mut *self.err, meta)?;
513        Ok(())
514    }
515
516    fn resource_describe(
517        &mut self,
518        detail: &crate::model::ResourceDetail,
519        meta: &MetaContext,
520    ) -> Result<(), Diagnostic> {
521        // Lines format: `detail.values` selects the shape (dsp-cli/ADR-0013 D1).
522        // None → the metadata row (single row, label TAB iri lean default), unchanged.
523        // Some(fields) → one row per value (long-format), metadata row dropped.
524        match &detail.values {
525            None => {
526                let rows = vec![build_metadata_row(detail)];
527                let projected = self.options.projected();
528                let spec = TableSpec {
529                    all_columns: RESOURCE_DESCRIBE_COLUMNS,
530                    rows: &rows,
531                    default_columns: Some(&["label", "iri"]),
532                    projected,
533                    quote: QuoteMode::Lines,
534                    header: HeaderMode::Off,
535                };
536                render_table(&mut *self.out, &spec)?;
537            }
538            Some(fields) => {
539                let rows = build_value_rows(&detail.label, &detail.iri, fields);
540                let projected = self.options.projected();
541                let spec = TableSpec {
542                    all_columns: RESOURCE_DESCRIBE_VALUES_COLUMNS,
543                    rows: &rows,
544                    default_columns: Some(RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS),
545                    projected,
546                    quote: QuoteMode::Lines,
547                    header: HeaderMode::Off,
548                };
549                render_table(&mut *self.out, &spec)?;
550            }
551        }
552        render_table_disclosure(&mut *self.err, meta)?;
553        Ok(())
554    }
555
556    fn vocabularies(&mut self, view: &VocabularyListView, meta: &MetaContext) -> Result<(), Diagnostic> {
557        // Lines format: name TAB iri per item, no header — lean chaining
558        // format, language-neutral (D4 forbids picking a language even for
559        // the default). `nodes`/`depth` are reachable via `--columns`.
560        let rows = build_vocabulary_list_rows(&view.items);
561        let projected = self.options.projected();
562        let spec = TableSpec {
563            all_columns: VOCABULARIES_COLUMNS,
564            rows: &rows,
565            default_columns: Some(&["name", "iri"]),
566            projected,
567            quote: QuoteMode::Lines,
568            header: HeaderMode::Off,
569        };
570        render_table(&mut *self.out, &spec)?;
571        render_table_disclosure(&mut *self.err, meta)?;
572        Ok(())
573    }
574
575    fn vocabulary_describe(&mut self, detail: &VocabularyDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
576        // Lines format: one row per node — node_iri TAB number, no header.
577        // `number` (language-neutral, D4) is the one column that makes the
578        // hierarchy legible in a flat format.
579        let rows = build_vocabulary_rows(detail);
580        let projected = self.options.projected();
581        let spec = TableSpec {
582            all_columns: VOCABULARY_DESCRIBE_COLUMNS,
583            rows: &rows,
584            default_columns: Some(&["node_iri", "number"]),
585            projected,
586            quote: QuoteMode::Lines,
587            header: HeaderMode::Off,
588        };
589        render_table(&mut *self.out, &spec)?;
590        render_table_disclosure(&mut *self.err, meta)?;
591        Ok(())
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598    use crate::model::{
599        DataModel, DataModelDetail, Project, ProjectDetail, ProjectStatus, ResourceType, ResourceTypeSummary,
600    };
601    use crate::render::test_support::{SharedBuf, make_meta};
602    use crate::render::{DataModelListView, ResourceTypeListView};
603
604    fn make_fixture() -> Vec<Project> {
605        vec![
606            Project {
607                iri: "http://rdfh.ch/projects/0001".into(),
608                shortcode: "0001".into(),
609                shortname: "anything".into(),
610                longname: Some("Anything Project".into()),
611                status: ProjectStatus::Active,
612                data_models: 2,
613            },
614            Project {
615                iri: "http://rdfh.ch/projects/0002".into(),
616                shortcode: "0002".into(),
617                shortname: "images".into(),
618                longname: None,
619                status: ProjectStatus::Inactive,
620                data_models: 0,
621            },
622            Project {
623                iri: "http://rdfh.ch/projects/0803".into(),
624                shortcode: "0803".into(),
625                shortname: "daschland".into(),
626                longname: Some("DaSCHland Project".into()),
627                status: ProjectStatus::Active,
628                data_models: 1,
629            },
630        ]
631    }
632
633    #[test]
634    fn projects_lines_output() {
635        let out = SharedBuf::new();
636        let err = SharedBuf::new();
637        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
638        let view = ProjectListView { items: make_fixture(), total: 3, filter: None };
639        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
640        renderer.projects(&view, &meta).unwrap();
641
642        let stdout = out.string();
643        // No header
644        assert!(!stdout.starts_with("shortcode"));
645        // Three rows: shortcode TAB shortname TAB longname
646        assert!(stdout.contains("0001\tanything\tAnything Project\n"));
647        // None longname → trailing tab (empty third field)
648        assert!(stdout.contains("0002\timages\t\n"));
649        assert!(stdout.contains("0803\tdaschland\tDaSCHland Project\n"));
650    }
651
652    #[test]
653    fn projects_lines_stderr_disclosure() {
654        let out = SharedBuf::new();
655        let err = SharedBuf::new();
656        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
657        let view = ProjectListView { items: make_fixture(), total: 3, filter: None };
658        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
659        renderer.projects(&view, &meta).unwrap();
660
661        let err_str = err.string();
662        assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
663        assert!(!out.string().contains("[anonymous"));
664    }
665
666    #[test]
667    fn auth_status_lines_stderr_disclosure() {
668        // AuthenticatedViaEnv — the env-vs-cache gap this fix closes
669        let out = SharedBuf::new();
670        let err = SharedBuf::new();
671        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
672        let meta = make_meta("authenticated via DSP_TOKEN", "https://api.prod.dasch.swiss");
673        renderer
674            .auth_status(
675                &AuthStatusOutcome::AuthenticatedViaEnv {
676                    server: "https://api.prod.dasch.swiss".to_string(),
677                    expires_at: None,
678                    expired: false,
679                },
680                &meta,
681            )
682            .unwrap();
683        let err_str = err.string();
684        assert_eq!(err_str.trim(), "[authenticated via DSP_TOKEN on https://api.prod.dasch.swiss]");
685        // Disclosure must not appear on stdout
686        assert!(!out.string().contains('['));
687
688        // NotLoggedIn — disclosure emitted for every outcome
689        let out2 = SharedBuf::new();
690        let err2 = SharedBuf::new();
691        let mut renderer2 = LinesRenderer::with_writers(out2.clone(), err2.clone());
692        let meta2 = make_meta("anonymous", "https://api.prod.dasch.swiss");
693        renderer2
694            .auth_status(
695                &AuthStatusOutcome::NotLoggedIn { server: "https://api.prod.dasch.swiss".to_string() },
696                &meta2,
697            )
698            .unwrap();
699        let err2_str = err2.string();
700        assert_eq!(err2_str.trim(), "[anonymous on https://api.prod.dasch.swiss]");
701        // Disclosure must not appear on stdout (symmetry with the env case above)
702        assert!(!out2.string().contains('['));
703    }
704
705    #[test]
706    fn projects_lines_with_writer_no_stderr_leak() {
707        let out = SharedBuf::new();
708        let mut renderer = LinesRenderer::with_writer(out.clone());
709        let view = ProjectListView { items: vec![], total: 0, filter: None };
710        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
711        renderer.projects(&view, &meta).unwrap();
712        // Empty list → no data rows; disclosure discarded to sink
713        assert_eq!(out.string(), "");
714    }
715
716    #[test]
717    fn project_describe_lines_output() {
718        let out = SharedBuf::new();
719        let err = SharedBuf::new();
720        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
721        let detail = ProjectDetail {
722            iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
723            shortcode: "0801".into(),
724            shortname: "beol".into(),
725            longname: Some("Bernoulli-Euler Online".into()),
726            status: ProjectStatus::Active,
727            description: vec![],
728            keywords: vec![],
729            data_models: vec![],
730        };
731        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
732        renderer.project_describe(&detail, &meta).unwrap();
733
734        let stdout = out.string();
735        // Single row: shortcode TAB shortname TAB longname
736        assert!(stdout.contains("0801\tbeol\tBernoulli-Euler Online\n"));
737        // No header
738        assert!(!stdout.starts_with("shortcode"));
739    }
740
741    #[test]
742    fn project_describe_lines_no_longname_trailing_tab() {
743        let out = SharedBuf::new();
744        let err = SharedBuf::new();
745        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
746        let detail = ProjectDetail {
747            iri: "http://rdfh.ch/projects/0000".into(),
748            shortcode: "0000".into(),
749            shortname: "minimal".into(),
750            longname: None,
751            status: ProjectStatus::Inactive,
752            description: vec![],
753            keywords: vec![],
754            data_models: vec![],
755        };
756        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
757        renderer.project_describe(&detail, &meta).unwrap();
758
759        let stdout = out.string();
760        // None longname → trailing tab (empty third field)
761        assert!(stdout.contains("0000\tminimal\t\n"));
762    }
763
764    #[test]
765    fn project_describe_lines_stderr_disclosure() {
766        let out = SharedBuf::new();
767        let err = SharedBuf::new();
768        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
769        let detail = ProjectDetail {
770            iri: "http://rdfh.ch/projects/0001".into(),
771            shortcode: "0001".into(),
772            shortname: "test".into(),
773            longname: None,
774            status: ProjectStatus::Active,
775            description: vec![],
776            keywords: vec![],
777            data_models: vec![],
778        };
779        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
780        renderer.project_describe(&detail, &meta).unwrap();
781
782        let err_str = err.string();
783        assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
784        // Disclosure must not appear on stdout
785        assert!(!out.string().contains("[anonymous"));
786    }
787
788    fn make_data_model_fixture() -> Vec<DataModel> {
789        vec![
790            DataModel {
791                name: "beol".into(),
792                iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
793                label: Some("The BEOL data-model".into()),
794                last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
795                is_builtin: false,
796            },
797            DataModel {
798                name: "biblio".into(),
799                iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
800                label: None,
801                last_modified: None,
802                is_builtin: false,
803            },
804            DataModel {
805                name: "knora-api".into(),
806                iri: "http://api.knora.org/ontology/knora-api/v2".into(),
807                label: None,
808                last_modified: None,
809                is_builtin: true,
810            },
811        ]
812    }
813
814    #[test]
815    fn data_models_lines_output() {
816        let out = SharedBuf::new();
817        let err = SharedBuf::new();
818        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
819        let view = DataModelListView { items: make_data_model_fixture(), total: 3, filter: None };
820        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
821        renderer.data_models(&view, &meta).unwrap();
822
823        let stdout = out.string();
824        // No header
825        assert!(!stdout.starts_with("name"));
826        // name TAB iri rows
827        assert!(stdout.contains("beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\n"));
828        assert!(stdout.contains("biblio\thttp://api.dasch.swiss/ontology/0801/biblio/v2\n"));
829        assert!(stdout.contains("knora-api\thttp://api.knora.org/ontology/knora-api/v2\n"));
830        // label/last_modified/is_builtin are NOT in lines output
831        assert!(!stdout.contains("The BEOL"));
832        assert!(!stdout.contains("2024-05-27"));
833        assert!(!stdout.contains("true"));
834    }
835
836    #[test]
837    fn data_models_lines_stderr_disclosure() {
838        let out = SharedBuf::new();
839        let err = SharedBuf::new();
840        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
841        let view = DataModelListView { items: make_data_model_fixture(), total: 3, filter: None };
842        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
843        renderer.data_models(&view, &meta).unwrap();
844
845        let err_str = err.string();
846        assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
847        // Disclosure must not appear on stdout
848        assert!(!out.string().contains("[anonymous"));
849    }
850
851    // ── data_model_describe lines tests ──────────────────────────────────────
852
853    fn make_beol_dm_detail() -> DataModelDetail {
854        DataModelDetail {
855            name: "beol".into(),
856            iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
857            label: Some("The BEOL data-model".into()),
858            last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
859            resource_types: vec![
860                ResourceTypeSummary {
861                    name: "Archive".into(),
862                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
863                    label: Some("Archive".into()),
864                },
865                ResourceTypeSummary {
866                    name: "basicLetter".into(),
867                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
868                    label: None,
869                },
870                ResourceTypeSummary {
871                    name: "letter".into(),
872                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
873                    label: Some("Letter".into()),
874                },
875            ],
876        }
877    }
878
879    #[test]
880    fn data_model_describe_lines_output() {
881        let out = SharedBuf::new();
882        let err = SharedBuf::new();
883        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
884        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
885        renderer.data_model_describe(&make_beol_dm_detail(), &meta).unwrap();
886
887        let stdout = out.string();
888        // Single row: name TAB iri
889        assert!(
890            stdout.contains("beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\n"),
891            "expected name TAB iri row; got:\n{stdout}"
892        );
893        // No header
894        assert!(!stdout.starts_with("name"));
895        // resource-types must NOT appear in lines output
896        assert!(!stdout.contains("Archive"), "resource-types must be omitted");
897    }
898
899    #[test]
900    fn data_model_describe_lines_stderr_disclosure() {
901        let out = SharedBuf::new();
902        let err = SharedBuf::new();
903        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
904        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
905        renderer.data_model_describe(&make_beol_dm_detail(), &meta).unwrap();
906
907        let err_str = err.string();
908        assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
909        // Disclosure must not appear on stdout
910        assert!(!out.string().contains("[anonymous"));
911    }
912
913    // ── resource_types lines tests ────────────────────────────────────────────
914
915    fn make_resource_type_fixture() -> Vec<ResourceType> {
916        vec![
917            ResourceType {
918                name: "Archive".into(),
919                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
920                label: Some("Archive".into()),
921                is_builtin: false,
922                count: None,
923            },
924            ResourceType {
925                name: "letter".into(),
926                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
927                label: Some("Letter".into()),
928                is_builtin: false,
929                count: None,
930            },
931        ]
932    }
933
934    #[test]
935    fn resource_types_lines_output() {
936        let out = SharedBuf::new();
937        let err = SharedBuf::new();
938        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
939        let view = ResourceTypeListView {
940            items: make_resource_type_fixture(),
941            total: 2,
942            filter: None,
943            data_model: "beol".into(),
944        };
945        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
946        renderer.resource_types(&view, &meta).unwrap();
947
948        let stdout = out.string();
949        // No header
950        assert!(!stdout.starts_with("name"));
951        // name TAB iri rows
952        assert!(stdout.contains("Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\n"));
953        assert!(stdout.contains("letter\thttp://api.dasch.swiss/ontology/0801/beol/v2#letter\n"));
954        // label/is_builtin NOT in lines output
955        assert!(!stdout.contains("Letter"));
956        assert!(!stdout.contains("true"));
957        assert!(!stdout.contains("false"));
958    }
959
960    #[test]
961    fn resource_types_lines_with_builtins() {
962        let out = SharedBuf::new();
963        let err = SharedBuf::new();
964        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
965        let mut items = make_resource_type_fixture();
966        items.push(ResourceType {
967            name: "Region".into(),
968            iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
969            label: Some("Region".into()),
970            is_builtin: true,
971            count: None,
972        });
973        let view = ResourceTypeListView { items, total: 3, filter: None, data_model: "beol".into() };
974        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
975        renderer.resource_types(&view, &meta).unwrap();
976
977        let stdout = out.string();
978        // built-in appears as a plain name TAB iri row
979        assert!(stdout.contains("Region\thttp://api.knora.org/ontology/knora-api/v2#Region\n"));
980        // no is_builtin column
981        assert!(!stdout.contains("true"));
982    }
983
984    #[test]
985    fn resource_types_lines_stderr_disclosure() {
986        let out = SharedBuf::new();
987        let err = SharedBuf::new();
988        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
989        let view = ResourceTypeListView {
990            items: make_resource_type_fixture(),
991            total: 2,
992            filter: None,
993            data_model: "beol".into(),
994        };
995        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
996        renderer.resource_types(&view, &meta).unwrap();
997
998        let err_str = err.string();
999        assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
1000        assert!(!out.string().contains("[anonymous"));
1001    }
1002
1003    #[test]
1004    fn resource_types_lines_empty() {
1005        let out = SharedBuf::new();
1006        let err = SharedBuf::new();
1007        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1008        let view = ResourceTypeListView {
1009            items: vec![],
1010            total: 0,
1011            filter: None,
1012            data_model: "beol".into(),
1013        };
1014        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1015        renderer.resource_types(&view, &meta).unwrap();
1016
1017        // No data rows; disclosure still emitted to stderr
1018        assert_eq!(out.string(), "");
1019        assert!(err.string().contains("[anonymous on https://api.test.dasch.swiss]"));
1020    }
1021
1022    #[test]
1023    fn resource_types_lines_default_unchanged_count_only_via_columns() {
1024        // plan 030: lines' default stays fixed at ["name", "iri"] regardless
1025        // of --count (unlike csv/tsv, which auto-show). `count` is only
1026        // reachable via explicit --columns.
1027        let out = SharedBuf::new();
1028        let err = SharedBuf::new();
1029        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1030        let mut items = make_resource_type_fixture();
1031        items[0].count = Some(10);
1032        let view = ResourceTypeListView { items, total: 2, filter: None, data_model: "beol".into() };
1033        let meta = crate::render::MetaContext {
1034            server_label: "https://api.test.dasch.swiss".into(),
1035            auth_state: "anonymous".into(),
1036            filter_warning: None,
1037            count_caveat: Some("counts are not permission-filtered".into()),
1038            count_cost: None,
1039        };
1040        renderer.resource_types(&view, &meta).unwrap();
1041
1042        let stdout = out.string();
1043        // Default output stays name TAB iri only — no count cell leaks in.
1044        assert!(stdout.contains("Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\n"));
1045        assert!(
1046            !stdout.contains("10"),
1047            "count must not leak into default output; got:\n{stdout}"
1048        );
1049        assert!(
1050            err.string().contains("counts are not permission-filtered"),
1051            "stderr must carry count_caveat; got: {:?}",
1052            err.string()
1053        );
1054    }
1055
1056    #[test]
1057    fn resource_types_lines_columns_count_explicit_projection() {
1058        let out = SharedBuf::new();
1059        let mut renderer = LinesRenderer::with_writer(out.clone()).with_options(TableOptions {
1060            columns: Some(vec!["name".to_string(), "count".to_string()]),
1061            header: HeaderMode::Off,
1062        });
1063        let mut items = make_resource_type_fixture();
1064        items[0].count = Some(10);
1065        let view = ResourceTypeListView { items, total: 2, filter: None, data_model: "beol".into() };
1066        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1067        renderer.resource_types(&view, &meta).unwrap();
1068
1069        let stdout = out.string();
1070        assert!(stdout.contains("Archive\t10\n"), "got:\n{stdout}");
1071        // letter has no count -> empty cell after the tab.
1072        assert!(stdout.contains("letter\t\n"), "got:\n{stdout}");
1073    }
1074
1075    #[test]
1076    fn resource_type_describe_lines_columns_no_count_column_exists() {
1077        // Part G scope note: resource_type_describe is untouched by this
1078        // step — `count` (a resource-type-level field) has no column in this
1079        // row-per-field table. `--columns count` must be a usage error.
1080        let out = SharedBuf::new();
1081        let mut renderer = LinesRenderer::with_writer(out.clone()).with_options(TableOptions {
1082            columns: Some(vec!["count".to_string()]),
1083            header: HeaderMode::Off,
1084        });
1085        let detail = crate::model::ResourceTypeDetail {
1086            name: "manuscript".into(),
1087            iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1088            label: Some("Manuscript".into()),
1089            data_model: "beol".into(),
1090            representation: None,
1091            super_types: vec![],
1092            fields: vec![],
1093            count: Some(7),
1094        };
1095        let meta = make_meta("anonymous", "https://api.dasch.swiss");
1096        let result = renderer.resource_type_describe(&detail, &meta);
1097        assert!(result.is_err(), "count is not a valid column for resource_type_describe");
1098    }
1099
1100    // ── auth_logout lines tests (D6 bypass + projected path) ─────────────────
1101
1102    #[test]
1103    fn auth_logout_lines_default_decorated_output() {
1104        // Default path: bespoke writeln! produces <server>\twas_cached=<bool>.
1105        let out = SharedBuf::new();
1106        let mut renderer = LinesRenderer::with_writer(out.clone());
1107        renderer
1108            .auth_logout(
1109                &AuthLogoutOutcome {
1110                    server: "https://api.example.com".to_string(),
1111                    was_cached: true,
1112                },
1113                &make_meta("anonymous", "https://api.example.com"),
1114            )
1115            .unwrap();
1116        assert_eq!(out.string(), "https://api.example.com\twas_cached=true\n");
1117    }
1118
1119    #[test]
1120    fn auth_logout_lines_projected_was_cached_plain_value() {
1121        // Projected path via engine: --columns was_cached → "true\n" (plain bool string,
1122        // no "was_cached=" prefix). Pins plan 020 D6's projected path contract.
1123        let out = SharedBuf::new();
1124        let mut renderer = LinesRenderer::with_writer(out.clone()).with_options(TableOptions {
1125            columns: Some(vec!["was_cached".to_string()]),
1126            header: HeaderMode::Off,
1127        });
1128        renderer
1129            .auth_logout(
1130                &AuthLogoutOutcome {
1131                    server: "https://api.example.com".to_string(),
1132                    was_cached: true,
1133                },
1134                &make_meta("anonymous", "https://api.example.com"),
1135            )
1136            .unwrap();
1137        assert_eq!(out.string(), "true\n");
1138    }
1139
1140    // ── resource_describe lines values-note tests ─────────────────────────────
1141
1142    use crate::model::{FieldValues, ResourceAccess, ResourceDetail, ResourceVisibility, ValueContent};
1143
1144    fn make_resource_detail(values: Option<Vec<FieldValues>>) -> ResourceDetail {
1145        ResourceDetail {
1146            label: "Test Resource".into(),
1147            iri: "http://rdfh.ch/0803/abc123".into(),
1148            resource_type: "Page".into(),
1149            ark_url: None,
1150            creation_date: None,
1151            last_modified: None,
1152            attached_project: None,
1153            owner: None,
1154            visibility: Some(ResourceVisibility::Public),
1155            your_access: Some(ResourceAccess::View),
1156            values,
1157        }
1158    }
1159
1160    #[test]
1161    fn resource_describe_lines_no_values_no_note() {
1162        // When values is None, no stderr note is emitted; the metadata row renders
1163        // unchanged.
1164        let out = SharedBuf::new();
1165        let err = SharedBuf::new();
1166        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1167        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1168        renderer.resource_describe(&make_resource_detail(None), &meta).unwrap();
1169
1170        let err_str = err.string();
1171        assert!(
1172            !err_str.contains("values not shown"),
1173            "no values note must be emitted when values is None; got stderr:\n{err_str}"
1174        );
1175        // Metadata row still present on stdout
1176        assert!(
1177            out.string().contains("Test Resource"),
1178            "metadata row missing; got:\n{}",
1179            out.string()
1180        );
1181    }
1182
1183    #[test]
1184    fn resource_describe_lines_some_values_no_note_renders_value_rows() {
1185        // When values is Some, no stderr note is emitted; stdout carries one row
1186        // per value in the compact default columns (field, field_label, value_type,
1187        // value) — no label/iri (opt-in only).
1188        let out = SharedBuf::new();
1189        let err = SharedBuf::new();
1190        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1191        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1192        renderer
1193            .resource_describe(
1194                &make_resource_detail(Some(vec![FieldValues {
1195                    name: "hasTitle".into(),
1196                    label: None,
1197                    values: vec![ValueContent::Text("Hello".into()).into()],
1198                }])),
1199                &meta,
1200            )
1201            .unwrap();
1202
1203        let err_str = err.string();
1204        assert!(
1205            !err_str.contains("values not shown"),
1206            "no values note must be emitted when values is Some; got stderr:\n{err_str}"
1207        );
1208        let stdout = out.string();
1209        assert!(
1210            stdout.contains("hasTitle\t\ttext\tHello\n"),
1211            "expected default-columns value row field\\tfield_label\\tvalue_type\\tvalue; got:\n{stdout}"
1212        );
1213        // Metadata (label/iri) is dropped in values mode by default.
1214        assert!(
1215            !stdout.contains("Test Resource"),
1216            "metadata row must not appear in values mode; got:\n{stdout}"
1217        );
1218    }
1219
1220    #[test]
1221    fn resource_describe_lines_columns_comment_renders_comment_column() {
1222        // comment is opt-in via --columns; selecting it must surface the
1223        // per-value comment text in the projected column position, and the
1224        // default-columns test above must stay unaffected (comment absent).
1225        use crate::model::Value;
1226
1227        let out = SharedBuf::new();
1228        let err = SharedBuf::new();
1229        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone()).with_options(TableOptions {
1230            columns: Some(vec!["field".to_string(), "value".to_string(), "comment".to_string()]),
1231            header: HeaderMode::Off,
1232        });
1233        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1234        renderer
1235            .resource_describe(
1236                &make_resource_detail(Some(vec![FieldValues {
1237                    name: "hasTranscription".into(),
1238                    label: None,
1239                    values: vec![Value {
1240                        content: ValueContent::Text("teh book".into()),
1241                        comment: Some("reading uncertain".into()),
1242                    }],
1243                }])),
1244                &meta,
1245            )
1246            .unwrap();
1247
1248        let stdout = out.string();
1249        assert!(
1250            stdout.contains("hasTranscription\tteh book\treading uncertain\n"),
1251            "expected projected field/value/comment row; got:\n{stdout}"
1252        );
1253    }
1254
1255    #[test]
1256    fn resource_describe_lines_default_columns_omits_comment_even_when_present() {
1257        // comment is opt-in via --columns, NOT part of the default column set —
1258        // this must hold even when the underlying value DOES carry a comment
1259        // (distinct from `resource_describe_lines_some_values_no_note_renders_value_rows`,
1260        // whose fixture has no comment at all and so cannot demonstrate this).
1261        use crate::model::Value;
1262
1263        let out = SharedBuf::new();
1264        let err = SharedBuf::new();
1265        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1266        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1267        renderer
1268            .resource_describe(
1269                &make_resource_detail(Some(vec![FieldValues {
1270                    name: "hasTranscription".into(),
1271                    label: None,
1272                    values: vec![Value {
1273                        content: ValueContent::Text("teh book".into()),
1274                        comment: Some("reading uncertain".into()),
1275                    }],
1276                }])),
1277                &meta,
1278            )
1279            .unwrap();
1280
1281        let stdout = out.string();
1282        assert!(
1283            stdout.contains("hasTranscription\t\ttext\tteh book\n"),
1284            "expected default-columns row field\\tfield_label\\tvalue_type\\tvalue \
1285             (no comment column); got:\n{stdout}"
1286        );
1287        assert!(
1288            !stdout.contains("reading uncertain"),
1289            "comment must NOT appear under default columns even though the value \
1290             has one — comment is opt-in via --columns, not in the default set; \
1291             got:\n{stdout}"
1292        );
1293    }
1294}