Skip to main content

dsp_cli/render/
tsv.rs

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