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        eprintln!("Error: {diag}"); // errors go to stderr for non-JSON formats (ADR-0012)
95        Ok(())
96    }
97
98    fn auth_login(
99        &mut self,
100        outcome: &AuthLoginOutcome,
101        _meta: &MetaContext,
102    ) -> Result<(), Diagnostic> {
103        let expires = outcome
104            .expires_at
105            .map(|dt| dt.to_rfc3339())
106            .unwrap_or_default();
107        let rows = vec![vec![
108            outcome.server.clone(),
109            outcome.user.clone(),
110            expires,
111            "login_success".to_string(),
112        ]];
113        let projected = self.options.projected();
114        let spec = TableSpec {
115            all_columns: AUTH_LOGIN_COLUMNS,
116            rows: &rows,
117            default_columns: None,
118            projected,
119            quote: QuoteMode::Tsv,
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::Tsv,
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::Tsv,
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::Tsv,
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        // TSV format: path only; cleanup disclosure is omitted (prose/json only).
230        // reused/created_at are also prose/json only.
231        let rows = vec![vec![outcome.path.display().to_string()]];
232        let projected = self.options.projected();
233        let spec = TableSpec {
234            all_columns: PROJECT_DUMP_COLUMNS,
235            rows: &rows,
236            default_columns: None,
237            projected,
238            quote: QuoteMode::Tsv,
239            header: self.options.header,
240        };
241        render_table(&mut *self.out, &spec)
242    }
243
244    fn project_dump_deleted(
245        &mut self,
246        outcome: &DumpDeleteOutcome,
247        _meta: &MetaContext,
248    ) -> Result<(), Diagnostic> {
249        // TSV format: header "deleted" + one row "true" or "false".
250        let rows = vec![vec![outcome.deleted.to_string()]];
251        let projected = self.options.projected();
252        let spec = TableSpec {
253            all_columns: PROJECT_DUMP_DELETED_COLUMNS,
254            rows: &rows,
255            default_columns: None,
256            projected,
257            quote: QuoteMode::Tsv,
258            header: self.options.header,
259        };
260        render_table(&mut *self.out, &spec)
261    }
262
263    fn projects(&mut self, view: &ProjectListView, meta: &MetaContext) -> Result<(), Diagnostic> {
264        let rows: Vec<Vec<String>> = view
265            .items
266            .iter()
267            .map(|item| {
268                let longname = item.longname.as_deref().unwrap_or("").to_string();
269                let data_models_str = item.data_models.to_string();
270                vec![
271                    item.shortcode.clone(),
272                    item.shortname.clone(),
273                    longname,
274                    item.status.as_str().to_string(),
275                    data_models_str,
276                    item.iri.clone(),
277                ]
278            })
279            .collect();
280        let projected = self.options.projected();
281        let spec = TableSpec {
282            all_columns: PROJECTS_COLUMNS,
283            rows: &rows,
284            default_columns: None,
285            projected,
286            quote: QuoteMode::Tsv,
287            header: self.options.header,
288        };
289        render_table(&mut *self.out, &spec)?;
290        render_table_disclosure(&mut *self.err, meta)?;
291        Ok(())
292    }
293
294    fn project_describe(
295        &mut self,
296        project: &ProjectDetail,
297        meta: &MetaContext,
298    ) -> Result<(), Diagnostic> {
299        // TSV format: header + one data row. Columns mirror `project list` TSV.
300        // `data_models` is the count (rich fields like names stay prose/json only).
301        let longname = project.longname.as_deref().unwrap_or("").to_string();
302        let data_models_str = project.data_models.len().to_string();
303        let rows = vec![vec![
304            project.shortcode.clone(),
305            project.shortname.clone(),
306            longname,
307            project.status.as_str().to_string(),
308            data_models_str,
309            project.iri.clone(),
310        ]];
311        let projected = self.options.projected();
312        let spec = TableSpec {
313            all_columns: PROJECTS_COLUMNS,
314            rows: &rows,
315            default_columns: None,
316            projected,
317            quote: QuoteMode::Tsv,
318            header: self.options.header,
319        };
320        render_table(&mut *self.out, &spec)?;
321        render_table_disclosure(&mut *self.err, meta)?;
322        Ok(())
323    }
324
325    fn data_model_describe(
326        &mut self,
327        detail: &DataModelDetail,
328        meta: &MetaContext,
329    ) -> Result<(), Diagnostic> {
330        // TSV format: header + one data row. Columns mirror the CSV shape.
331        // `resource_types` carries the count, mirroring `project_describe` tsv.
332        let label = detail.label.as_deref().unwrap_or("").to_string();
333        let last_modified = detail.last_modified.as_deref().unwrap_or("").to_string();
334        let resource_types_str = detail.resource_types.len().to_string();
335        let rows = vec![vec![
336            detail.name.clone(),
337            detail.iri.clone(),
338            label,
339            last_modified,
340            resource_types_str,
341        ]];
342        let projected = self.options.projected();
343        let spec = TableSpec {
344            all_columns: DATA_MODEL_DESCRIBE_COLUMNS,
345            rows: &rows,
346            default_columns: None,
347            projected,
348            quote: QuoteMode::Tsv,
349            header: self.options.header,
350        };
351        render_table(&mut *self.out, &spec)?;
352        render_table_disclosure(&mut *self.err, meta)?;
353        Ok(())
354    }
355
356    fn data_models(
357        &mut self,
358        view: &DataModelListView,
359        meta: &MetaContext,
360    ) -> Result<(), Diagnostic> {
361        let rows: Vec<Vec<String>> = view
362            .items
363            .iter()
364            .map(|item| {
365                let label = item.label.as_deref().unwrap_or("").to_string();
366                let last_modified = item.last_modified.as_deref().unwrap_or("").to_string();
367                let is_builtin_str = if item.is_builtin { "true" } else { "false" };
368                vec![
369                    item.name.clone(),
370                    item.iri.clone(),
371                    label,
372                    last_modified,
373                    is_builtin_str.to_string(),
374                ]
375            })
376            .collect();
377        let projected = self.options.projected();
378        let spec = TableSpec {
379            all_columns: DATA_MODELS_COLUMNS,
380            rows: &rows,
381            default_columns: None,
382            projected,
383            quote: QuoteMode::Tsv,
384            header: self.options.header,
385        };
386        render_table(&mut *self.out, &spec)?;
387        render_table_disclosure(&mut *self.err, meta)?;
388        Ok(())
389    }
390
391    fn resource_types(
392        &mut self,
393        view: &ResourceTypeListView,
394        meta: &MetaContext,
395    ) -> Result<(), Diagnostic> {
396        // TSV header: name\tiri\tlabel\tis_builtin\tcount. No last_modified —
397        // resource-types have none. `count` auto-shows only when at least one
398        // item carries one (plan 030) — see `default_columns` below.
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                let count_str = item.count.map(|c| c.to_string()).unwrap_or_default();
406                vec![
407                    item.name.clone(),
408                    item.iri.clone(),
409                    label,
410                    is_builtin_str.to_string(),
411                    count_str,
412                ]
413            })
414            .collect();
415        let has_counts = view.items.iter().any(|rt| rt.count.is_some());
416        let default_columns = if has_counts {
417            None
418        } else {
419            Some(RESOURCE_TYPES_DEFAULT_COLUMNS)
420        };
421        let projected = self.options.projected();
422        let spec = TableSpec {
423            all_columns: RESOURCE_TYPES_COLUMNS,
424            rows: &rows,
425            default_columns,
426            projected,
427            quote: QuoteMode::Tsv,
428            header: self.options.header,
429        };
430        render_table(&mut *self.out, &spec)?;
431        render_table_disclosure(&mut *self.err, meta)?;
432        Ok(())
433    }
434
435    fn resource_type_describe(
436        &mut self,
437        detail: &crate::model::ResourceTypeDetail,
438        meta: &MetaContext,
439    ) -> Result<(), Diagnostic> {
440        // TSV format: header row + one row per field. Mirrors CSV shape with tab separator.
441        // Full column set: name, iri, value_type, link_target, cardinality, label,
442        // is_builtin, data_model (8 columns; `iri` at position 1).
443        // Default (unflagged) output uses `RESOURCE_TYPE_DESCRIBE_DEFAULT_COLUMNS` —
444        // the 7-column set without `iri`, byte-identical to the pre-020 tsv output.
445        let rows: Vec<Vec<String>> = detail
446            .fields
447            .iter()
448            .map(|field| {
449                let value_type_str = field.value_type.to_string();
450                let link_target = field.link_target.as_deref().unwrap_or("").to_string();
451                let card_str = field.cardinality.to_string();
452                let label = field.label.as_deref().unwrap_or("").to_string();
453                let is_builtin_str = if field.is_builtin { "true" } else { "false" };
454                let data_model = field.data_model.as_deref().unwrap_or("").to_string();
455                vec![
456                    field.name.clone(),
457                    field.iri.clone(),
458                    value_type_str,
459                    link_target,
460                    card_str,
461                    label,
462                    is_builtin_str.to_string(),
463                    data_model,
464                ]
465            })
466            .collect();
467        let projected = self.options.projected();
468        let spec = TableSpec {
469            all_columns: RESOURCE_TYPE_DESCRIBE_COLUMNS,
470            rows: &rows,
471            default_columns: Some(RESOURCE_TYPE_DESCRIBE_DEFAULT_COLUMNS),
472            projected,
473            quote: QuoteMode::Tsv,
474            header: self.options.header,
475        };
476        render_table(&mut *self.out, &spec)?;
477        render_table_disclosure(&mut *self.err, meta)?;
478        Ok(())
479    }
480
481    fn data_model_structure(
482        &mut self,
483        structure: &DataModelStructure,
484        meta: &MetaContext,
485    ) -> Result<(), Diagnostic> {
486        // TSV format: header row + one row per relation. Mirrors CSV shape with tab separator.
487        // Columns: source, target, kind, field, target_data_model.
488        // Empty cell when field/target_data_model is None.
489        let rows: Vec<Vec<String>> = structure
490            .relations
491            .iter()
492            .map(|rel| {
493                let kind_str = rel.kind.to_string();
494                let field = rel.field.as_deref().unwrap_or("").to_string();
495                let target_dm = rel.target_data_model.as_deref().unwrap_or("").to_string();
496                vec![
497                    rel.source.clone(),
498                    rel.target.clone(),
499                    kind_str,
500                    field,
501                    target_dm,
502                ]
503            })
504            .collect();
505        let projected = self.options.projected();
506        let spec = TableSpec {
507            all_columns: DATA_MODEL_STRUCTURE_COLUMNS,
508            rows: &rows,
509            default_columns: None,
510            projected,
511            quote: QuoteMode::Tsv,
512            header: self.options.header,
513        };
514        render_table(&mut *self.out, &spec)?;
515        render_table_disclosure(&mut *self.err, meta)?;
516        Ok(())
517    }
518
519    fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
520        let rows: Vec<Vec<String>> = view
521            .items
522            .iter()
523            .map(|item| {
524                vec![
525                    item.label.clone(),
526                    item.iri.clone(),
527                    item.ark_url.as_deref().unwrap_or("").to_string(),
528                    item.creation_date.as_deref().unwrap_or("").to_string(),
529                    item.last_modified.as_deref().unwrap_or("").to_string(),
530                    item.resource_type.clone(),
531                ]
532            })
533            .collect();
534        let projected = self.options.projected();
535        let spec = TableSpec {
536            all_columns: RESOURCE_LIST_COLUMNS,
537            rows: &rows,
538            default_columns: None,
539            projected,
540            quote: QuoteMode::Tsv,
541            header: self.options.header,
542        };
543        render_table(&mut *self.out, &spec)?;
544        render_table_disclosure(&mut *self.err, meta)?;
545        Ok(())
546    }
547
548    fn resource_describe(
549        &mut self,
550        detail: &crate::model::ResourceDetail,
551        meta: &MetaContext,
552    ) -> Result<(), Diagnostic> {
553        // TSV format: `detail.values` selects the shape (ADR-0013 D1).
554        // None → header + metadata row (all ten RESOURCE_DESCRIBE_COLUMNS), unchanged.
555        // Some(fields) → header + one row per value (long-format), metadata dropped.
556        match &detail.values {
557            None => {
558                let rows = vec![build_metadata_row(detail)];
559                let projected = self.options.projected();
560                let spec = TableSpec {
561                    all_columns: RESOURCE_DESCRIBE_COLUMNS,
562                    rows: &rows,
563                    default_columns: None,
564                    projected,
565                    quote: QuoteMode::Tsv,
566                    header: self.options.header,
567                };
568                render_table(&mut *self.out, &spec)?;
569            }
570            Some(fields) => {
571                let rows = build_value_rows(&detail.label, &detail.iri, fields);
572                let projected = self.options.projected();
573                let spec = TableSpec {
574                    all_columns: RESOURCE_DESCRIBE_VALUES_COLUMNS,
575                    rows: &rows,
576                    default_columns: Some(RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS),
577                    projected,
578                    quote: QuoteMode::Tsv,
579                    header: self.options.header,
580                };
581                render_table(&mut *self.out, &spec)?;
582            }
583        }
584        render_table_disclosure(&mut *self.err, meta)?;
585        Ok(())
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use crate::model::{
593        DataModel, DataModelDetail, DataModelSummary, Project, ProjectDetail, ProjectStatus,
594        ResourceType, ResourceTypeSummary,
595    };
596    use crate::render::test_support::{SharedBuf, make_meta};
597    use crate::render::{DataModelListView, ResourceTypeListView};
598
599    fn make_fixture() -> Vec<Project> {
600        vec![
601            Project {
602                iri: "http://rdfh.ch/projects/0001".into(),
603                shortcode: "0001".into(),
604                shortname: "anything".into(),
605                longname: Some("Anything Project".into()),
606                status: ProjectStatus::Active,
607                data_models: 2,
608            },
609            Project {
610                iri: "http://rdfh.ch/projects/0002".into(),
611                shortcode: "0002".into(),
612                shortname: "images".into(),
613                longname: None,
614                status: ProjectStatus::Inactive,
615                data_models: 0,
616            },
617            Project {
618                iri: "http://rdfh.ch/projects/0803".into(),
619                shortcode: "0803".into(),
620                shortname: "daschland".into(),
621                longname: Some("DaSCHland Project".into()),
622                status: ProjectStatus::Active,
623                data_models: 1,
624            },
625        ]
626    }
627
628    #[test]
629    fn projects_tsv_output() {
630        let out = SharedBuf::new();
631        let err = SharedBuf::new();
632        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
633        let view = ProjectListView {
634            items: make_fixture(),
635            total: 3,
636            filter: None,
637        };
638        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
639        renderer.projects(&view, &meta).unwrap();
640
641        let stdout = out.string();
642        assert!(stdout.starts_with("shortcode\tshortname\tlongname\tstatus\tdata_models\tiri\n"));
643        assert!(
644            stdout.contains(
645                "0001\tanything\tAnything Project\tactive\t2\thttp://rdfh.ch/projects/0001"
646            )
647        );
648        // None longname → empty (trailing tab before status)
649        assert!(stdout.contains("0002\timages\t\tinactive\t0\thttp://rdfh.ch/projects/0002"));
650        assert!(stdout.contains(
651            "0803\tdaschland\tDaSCHland Project\tactive\t1\thttp://rdfh.ch/projects/0803"
652        ));
653    }
654
655    #[test]
656    fn projects_tsv_stderr_disclosure() {
657        let out = SharedBuf::new();
658        let err = SharedBuf::new();
659        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
660        let view = ProjectListView {
661            items: make_fixture(),
662            total: 3,
663            filter: None,
664        };
665        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
666        renderer.projects(&view, &meta).unwrap();
667
668        let err_str = err.string();
669        assert_eq!(
670            err_str.trim(),
671            "[anonymous on https://api.test.dasch.swiss]"
672        );
673        assert!(!out.string().contains("[anonymous"));
674    }
675
676    #[test]
677    fn auth_status_tsv_stderr_disclosure() {
678        // AuthenticatedViaEnv — the env-vs-cache gap this fix closes
679        let out = SharedBuf::new();
680        let err = SharedBuf::new();
681        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
682        let meta = make_meta(
683            "authenticated via DSP_TOKEN",
684            "https://api.prod.dasch.swiss",
685        );
686        renderer
687            .auth_status(
688                &AuthStatusOutcome::AuthenticatedViaEnv {
689                    server: "https://api.prod.dasch.swiss".to_string(),
690                    expires_at: None,
691                    expired: false,
692                },
693                &meta,
694            )
695            .unwrap();
696        let err_str = err.string();
697        assert_eq!(
698            err_str.trim(),
699            "[authenticated via DSP_TOKEN on https://api.prod.dasch.swiss]"
700        );
701        // Disclosure must not appear on stdout
702        assert!(!out.string().contains('['));
703
704        // NotLoggedIn — disclosure emitted for every outcome
705        let out2 = SharedBuf::new();
706        let err2 = SharedBuf::new();
707        let mut renderer2 = TsvRenderer::with_writers(out2.clone(), err2.clone());
708        let meta2 = make_meta("anonymous", "https://api.prod.dasch.swiss");
709        renderer2
710            .auth_status(
711                &AuthStatusOutcome::NotLoggedIn {
712                    server: "https://api.prod.dasch.swiss".to_string(),
713                },
714                &meta2,
715            )
716            .unwrap();
717        let err2_str = err2.string();
718        assert_eq!(
719            err2_str.trim(),
720            "[anonymous on https://api.prod.dasch.swiss]"
721        );
722        // Disclosure must not appear on stdout (symmetry with the env case above)
723        assert!(!out2.string().contains('['));
724    }
725
726    #[test]
727    fn projects_tsv_with_writer_no_stderr_leak() {
728        let out = SharedBuf::new();
729        let mut renderer = TsvRenderer::with_writer(out.clone());
730        let view = ProjectListView {
731            items: vec![],
732            total: 0,
733            filter: None,
734        };
735        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
736        renderer.projects(&view, &meta).unwrap();
737        assert_eq!(
738            out.string(),
739            "shortcode\tshortname\tlongname\tstatus\tdata_models\tiri\n"
740        );
741    }
742
743    #[test]
744    fn project_describe_tsv_output() {
745        let out = SharedBuf::new();
746        let err = SharedBuf::new();
747        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
748        let detail = ProjectDetail {
749            iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
750            shortcode: "0801".into(),
751            shortname: "beol".into(),
752            longname: Some("Bernoulli-Euler Online".into()),
753            status: ProjectStatus::Active,
754            description: vec![],
755            keywords: vec![],
756            data_models: vec![
757                DataModelSummary {
758                    name: "beol".into(),
759                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
760                },
761                DataModelSummary {
762                    name: "biblio".into(),
763                    iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
764                },
765            ],
766        };
767        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
768        renderer.project_describe(&detail, &meta).unwrap();
769
770        let stdout = out.string();
771        // Header row
772        assert!(stdout.starts_with("shortcode\tshortname\tlongname\tstatus\tdata_models\tiri\n"));
773        // Data row: data_models = count (2)
774        assert!(stdout.contains(
775            "0801\tbeol\tBernoulli-Euler Online\tactive\t2\thttp://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF"
776        ));
777    }
778
779    #[test]
780    fn project_describe_tsv_no_longname() {
781        let out = SharedBuf::new();
782        let mut renderer = TsvRenderer::with_writer(out.clone());
783        let detail = ProjectDetail {
784            iri: "http://rdfh.ch/projects/0000".into(),
785            shortcode: "0000".into(),
786            shortname: "minimal".into(),
787            longname: None,
788            status: ProjectStatus::Inactive,
789            description: vec![],
790            keywords: vec![],
791            data_models: vec![],
792        };
793        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
794        renderer.project_describe(&detail, &meta).unwrap();
795
796        let stdout = out.string();
797        // None longname → empty field (two consecutive tabs)
798        assert!(stdout.contains("0000\tminimal\t\tinactive\t0\thttp://rdfh.ch/projects/0000"));
799    }
800
801    #[test]
802    fn project_describe_tsv_stderr_disclosure() {
803        let out = SharedBuf::new();
804        let err = SharedBuf::new();
805        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
806        let detail = ProjectDetail {
807            iri: "http://rdfh.ch/projects/0001".into(),
808            shortcode: "0001".into(),
809            shortname: "test".into(),
810            longname: None,
811            status: ProjectStatus::Active,
812            description: vec![],
813            keywords: vec![],
814            data_models: vec![],
815        };
816        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
817        renderer.project_describe(&detail, &meta).unwrap();
818
819        let err_str = err.string();
820        assert_eq!(
821            err_str.trim(),
822            "[anonymous on https://api.test.dasch.swiss]"
823        );
824        assert!(!out.string().contains("[anonymous"));
825    }
826
827    fn make_data_model_fixture() -> Vec<DataModel> {
828        vec![
829            DataModel {
830                name: "beol".into(),
831                iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
832                label: Some("The BEOL data-model".into()),
833                last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
834                is_builtin: false,
835            },
836            DataModel {
837                name: "biblio".into(),
838                iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
839                label: None,
840                last_modified: None,
841                is_builtin: false,
842            },
843            DataModel {
844                name: "knora-api".into(),
845                iri: "http://api.knora.org/ontology/knora-api/v2".into(),
846                label: None,
847                last_modified: None,
848                is_builtin: true,
849            },
850        ]
851    }
852
853    #[test]
854    fn data_models_tsv_output() {
855        let out = SharedBuf::new();
856        let err = SharedBuf::new();
857        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
858        let view = DataModelListView {
859            items: make_data_model_fixture(),
860            total: 3,
861            filter: None,
862        };
863        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
864        renderer.data_models(&view, &meta).unwrap();
865
866        let stdout = out.string();
867        // Header row
868        assert!(stdout.starts_with("name\tiri\tlabel\tlast_modified\tis_builtin\n"));
869        // beol: has label and last_modified, not builtin
870        assert!(stdout.contains(
871            "beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\tThe BEOL data-model\t2024-05-27T13:43:26.233048Z\tfalse"
872        ));
873        // biblio: label None → empty, last_modified None → empty (two consecutive tabs)
874        assert!(
875            stdout.contains("biblio\thttp://api.dasch.swiss/ontology/0801/biblio/v2\t\t\tfalse")
876        );
877        // knora-api: builtin → "true"
878        assert!(stdout.contains("knora-api\thttp://api.knora.org/ontology/knora-api/v2\t\t\ttrue"));
879    }
880
881    #[test]
882    fn data_models_tsv_stderr_disclosure() {
883        let out = SharedBuf::new();
884        let err = SharedBuf::new();
885        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
886        let view = DataModelListView {
887            items: make_data_model_fixture(),
888            total: 3,
889            filter: None,
890        };
891        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
892        renderer.data_models(&view, &meta).unwrap();
893
894        let err_str = err.string();
895        assert_eq!(
896            err_str.trim(),
897            "[anonymous on https://api.test.dasch.swiss]"
898        );
899        assert!(!out.string().contains("[anonymous"));
900    }
901
902    // ── data_model_describe TSV tests ─────────────────────────────────────────
903
904    fn make_beol_dm_detail() -> DataModelDetail {
905        DataModelDetail {
906            name: "beol".into(),
907            iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
908            label: Some("The BEOL data-model".into()),
909            last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
910            resource_types: vec![
911                ResourceTypeSummary {
912                    name: "Archive".into(),
913                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
914                    label: Some("Archive".into()),
915                },
916                ResourceTypeSummary {
917                    name: "basicLetter".into(),
918                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
919                    label: None,
920                },
921                ResourceTypeSummary {
922                    name: "letter".into(),
923                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
924                    label: Some("Letter".into()),
925                },
926            ],
927        }
928    }
929
930    #[test]
931    fn data_model_describe_tsv_output() {
932        let out = SharedBuf::new();
933        let err = SharedBuf::new();
934        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
935        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
936        renderer
937            .data_model_describe(&make_beol_dm_detail(), &meta)
938            .unwrap();
939
940        let stdout = out.string();
941        // Header row
942        assert!(
943            stdout.starts_with("name\tiri\tlabel\tlast_modified\tresource_types\n"),
944            "header row missing; got:\n{stdout}"
945        );
946        // Data row: resource_types = count (3)
947        assert!(
948            stdout.contains(
949                "beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\tThe BEOL data-model\t2024-05-27T13:43:26.233048Z\t3"
950            ),
951            "data row missing; got:\n{stdout}"
952        );
953    }
954
955    #[test]
956    fn data_model_describe_tsv_no_label_no_last_modified() {
957        let out = SharedBuf::new();
958        let mut renderer = TsvRenderer::with_writer(out.clone());
959        let detail = DataModelDetail {
960            name: "minimal".into(),
961            iri: "http://api.dasch.swiss/ontology/0000/minimal/v2".into(),
962            label: None,
963            last_modified: None,
964            resource_types: vec![],
965        };
966        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
967        renderer.data_model_describe(&detail, &meta).unwrap();
968
969        let stdout = out.string();
970        // None label → empty, None last_modified → empty (two consecutive tabs)
971        assert!(
972            stdout.contains("minimal\thttp://api.dasch.swiss/ontology/0000/minimal/v2\t\t\t0"),
973            "None → empty fields must be handled; got:\n{stdout}"
974        );
975    }
976
977    #[test]
978    fn data_model_describe_tsv_stderr_disclosure() {
979        let out = SharedBuf::new();
980        let err = SharedBuf::new();
981        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
982        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
983        renderer
984            .data_model_describe(&make_beol_dm_detail(), &meta)
985            .unwrap();
986
987        let err_str = err.string();
988        assert_eq!(
989            err_str.trim(),
990            "[anonymous on https://api.test.dasch.swiss]"
991        );
992        assert!(!out.string().contains("[anonymous"));
993    }
994
995    // ── resource_types TSV tests ──────────────────────────────────────────────
996
997    fn make_resource_type_fixture() -> Vec<ResourceType> {
998        vec![
999            ResourceType {
1000                name: "Archive".into(),
1001                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1002                label: Some("Archive".into()),
1003                is_builtin: false,
1004                count: None,
1005            },
1006            ResourceType {
1007                name: "letter".into(),
1008                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1009                label: None,
1010                is_builtin: false,
1011                count: None,
1012            },
1013        ]
1014    }
1015
1016    #[test]
1017    fn resource_types_tsv_output() {
1018        let out = SharedBuf::new();
1019        let err = SharedBuf::new();
1020        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1021        let view = ResourceTypeListView {
1022            items: make_resource_type_fixture(),
1023            total: 2,
1024            filter: None,
1025            data_model: "beol".into(),
1026        };
1027        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1028        renderer.resource_types(&view, &meta).unwrap();
1029
1030        let stdout = out.string();
1031        // Header row (no last_modified column)
1032        assert!(
1033            stdout.starts_with("name\tiri\tlabel\tis_builtin\n"),
1034            "header row missing; got:\n{stdout}"
1035        );
1036        // Archive: has label, not builtin
1037        assert!(
1038            stdout.contains(
1039                "Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\tArchive\tfalse"
1040            ),
1041            "Archive row missing; got:\n{stdout}"
1042        );
1043        // letter: label None → empty (two consecutive tabs)
1044        assert!(
1045            stdout.contains("letter\thttp://api.dasch.swiss/ontology/0801/beol/v2#letter\t\tfalse"),
1046            "letter row missing; got:\n{stdout}"
1047        );
1048    }
1049
1050    #[test]
1051    fn resource_types_tsv_with_builtins() {
1052        let out = SharedBuf::new();
1053        let mut renderer = TsvRenderer::with_writer(out.clone());
1054        let view = ResourceTypeListView {
1055            items: vec![ResourceType {
1056                name: "Region".into(),
1057                iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1058                label: Some("Region".into()),
1059                is_builtin: true,
1060                count: None,
1061            }],
1062            total: 1,
1063            filter: None,
1064            data_model: "beol".into(),
1065        };
1066        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1067        renderer.resource_types(&view, &meta).unwrap();
1068
1069        let stdout = out.string();
1070        // built-in row with is_builtin = "true"
1071        assert!(
1072            stdout.contains(
1073                "Region\thttp://api.knora.org/ontology/knora-api/v2#Region\tRegion\ttrue"
1074            ),
1075            "built-in row missing; got:\n{stdout}"
1076        );
1077    }
1078
1079    #[test]
1080    fn resource_types_tsv_with_filter() {
1081        // filter does not affect the TSV output shape; just verify it renders cleanly
1082        let out = SharedBuf::new();
1083        let mut renderer = TsvRenderer::with_writer(out.clone());
1084        let view = ResourceTypeListView {
1085            items: vec![make_resource_type_fixture().remove(0)], // just Archive
1086            total: 2,
1087            filter: Some("arch".to_string()),
1088            data_model: "beol".into(),
1089        };
1090        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1091        renderer.resource_types(&view, &meta).unwrap();
1092
1093        let stdout = out.string();
1094        assert!(stdout.starts_with("name\tiri\tlabel\tis_builtin\n"));
1095        assert!(stdout.contains("Archive"));
1096    }
1097
1098    #[test]
1099    fn resource_types_tsv_empty() {
1100        let out = SharedBuf::new();
1101        let mut renderer = TsvRenderer::with_writer(out.clone());
1102        let view = ResourceTypeListView {
1103            items: vec![],
1104            total: 0,
1105            filter: None,
1106            data_model: "beol".into(),
1107        };
1108        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1109        renderer.resource_types(&view, &meta).unwrap();
1110
1111        // Only header row
1112        assert_eq!(out.string(), "name\tiri\tlabel\tis_builtin\n");
1113    }
1114
1115    #[test]
1116    fn resource_types_tsv_stderr_disclosure() {
1117        let out = SharedBuf::new();
1118        let err = SharedBuf::new();
1119        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1120        let view = ResourceTypeListView {
1121            items: make_resource_type_fixture(),
1122            total: 2,
1123            filter: None,
1124            data_model: "beol".into(),
1125        };
1126        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1127        renderer.resource_types(&view, &meta).unwrap();
1128
1129        let err_str = err.string();
1130        assert_eq!(
1131            err_str.trim(),
1132            "[anonymous on https://api.test.dasch.swiss]"
1133        );
1134        assert!(!out.string().contains("[anonymous"));
1135    }
1136
1137    #[test]
1138    fn resource_types_tsv_with_count_auto_shows_column() {
1139        // plan 030: when at least one item carries a count, the `count`
1140        // column auto-shows in the unflagged (no --columns) header, and the
1141        // count_caveat is appended to the stderr disclosure line.
1142        let out = SharedBuf::new();
1143        let err = SharedBuf::new();
1144        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1145        let mut items = make_resource_type_fixture();
1146        items[0].count = Some(10);
1147        let view = ResourceTypeListView {
1148            items,
1149            total: 2,
1150            filter: None,
1151            data_model: "beol".into(),
1152        };
1153        let meta = crate::render::MetaContext {
1154            server_label: "https://api.test.dasch.swiss".into(),
1155            auth_state: "anonymous".into(),
1156            filter_warning: None,
1157            count_caveat: Some("counts are not permission-filtered".into()),
1158        };
1159        renderer.resource_types(&view, &meta).unwrap();
1160
1161        let stdout = out.string();
1162        assert!(
1163            stdout.starts_with("name\tiri\tlabel\tis_builtin\tcount\n"),
1164            "header must auto-show count column; got:\n{stdout}"
1165        );
1166        assert!(
1167            stdout.contains(
1168                "Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\tArchive\tfalse\t10"
1169            ),
1170            "Archive row must show count 10; got:\n{stdout}"
1171        );
1172        let err_str = err.string();
1173        assert!(
1174            err_str.contains("counts are not permission-filtered"),
1175            "stderr must carry count_caveat; got: {err_str:?}"
1176        );
1177    }
1178
1179    #[test]
1180    fn resource_type_describe_tsv_with_count_no_column_but_disclosure_carries_caveat() {
1181        // Part F scope note: resource_type_describe gets ZERO csv/tsv changes —
1182        // `count` is per-resource-type, not per-field, so this row-per-field
1183        // table has no column for it. The only user-visible effect of a
1184        // `count`/`count_caveat` here is the stderr disclosure note, which
1185        // flows automatically through the Part B1 `disclosure_suffix` helper.
1186        let out = SharedBuf::new();
1187        let err = SharedBuf::new();
1188        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1189        let detail = crate::model::ResourceTypeDetail {
1190            name: "manuscript".into(),
1191            iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1192            label: Some("Manuscript".into()),
1193            data_model: "beol".into(),
1194            representation: None,
1195            super_types: vec![],
1196            fields: vec![],
1197            count: Some(7),
1198        };
1199        let meta = crate::render::MetaContext {
1200            server_label: "https://api.dasch.swiss".into(),
1201            auth_state: "anonymous".into(),
1202            filter_warning: None,
1203            count_caveat: Some("counts exclude deleted resources".into()),
1204        };
1205        renderer.resource_type_describe(&detail, &meta).unwrap();
1206
1207        // Table shape unchanged: header only, no `count` column.
1208        assert!(out.string().starts_with(
1209            "name\tvalue_type\tlink_target\tcardinality\tlabel\tis_builtin\tdata_model\n"
1210        ));
1211        // Disclosure note carries the caveat.
1212        assert!(
1213            err.string().contains("counts exclude deleted resources"),
1214            "stderr must carry count_caveat; got: {:?}",
1215            err.string()
1216        );
1217    }
1218
1219    // ── resource_describe tsv values-note tests ───────────────────────────────
1220
1221    use crate::model::{
1222        FieldValues, ResourceAccess, ResourceDetail, ResourceVisibility, ValueContent,
1223    };
1224
1225    fn make_resource_detail(values: Option<Vec<FieldValues>>) -> ResourceDetail {
1226        ResourceDetail {
1227            label: "Test Resource".into(),
1228            iri: "http://rdfh.ch/0803/abc123".into(),
1229            resource_type: "Page".into(),
1230            ark_url: None,
1231            creation_date: None,
1232            last_modified: None,
1233            attached_project: None,
1234            owner: None,
1235            visibility: Some(ResourceVisibility::Public),
1236            your_access: Some(ResourceAccess::View),
1237            values,
1238        }
1239    }
1240
1241    #[test]
1242    fn resource_describe_tsv_some_values_no_note_renders_value_rows() {
1243        // When values is Some, no stderr note; stdout carries the values header +
1244        // one row per value in the compact default columns. Metadata (label/iri)
1245        // is dropped by default in values mode.
1246        let out = SharedBuf::new();
1247        let err = SharedBuf::new();
1248        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1249        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1250        renderer
1251            .resource_describe(
1252                &make_resource_detail(Some(vec![FieldValues {
1253                    name: "hasTitle".into(),
1254                    label: None,
1255                    values: vec![ValueContent::Text("Hello".into()).into()],
1256                }])),
1257                &meta,
1258            )
1259            .unwrap();
1260
1261        let err_str = err.string();
1262        assert!(
1263            !err_str.contains("values not shown"),
1264            "no values note must be emitted when values is Some; got:\n{err_str}"
1265        );
1266        let stdout = out.string();
1267        assert!(
1268            stdout.starts_with("field\tfield_label\tvalue_type\tvalue\n"),
1269            "tsv values header must be the compact default set; got:\n{stdout}"
1270        );
1271        assert!(
1272            stdout.contains("hasTitle\t\ttext\tHello\n"),
1273            "expected value row for hasTitle; got:\n{stdout}"
1274        );
1275        // Metadata is dropped in values mode by default.
1276        assert!(
1277            !stdout.contains("Test Resource"),
1278            "metadata row must not appear in values mode; got:\n{stdout}"
1279        );
1280    }
1281
1282    #[test]
1283    fn resource_describe_tsv_no_values_no_note() {
1284        // When values is None, no stderr note.
1285        let out = SharedBuf::new();
1286        let err = SharedBuf::new();
1287        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1288        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1289        renderer
1290            .resource_describe(&make_resource_detail(None), &meta)
1291            .unwrap();
1292
1293        assert!(
1294            !err.string().contains("values not shown"),
1295            "no note expected when values is None; got:\n{}",
1296            err.string()
1297        );
1298    }
1299}