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