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