Skip to main content

dsp_cli/render/
csv.rs

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