Skip to main content

dsp_cli/render/
tsv.rs

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