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