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