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