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