Skip to main content

dsp_cli/render/
csv.rs

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