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    Renderer, ResourceListView, ResourceTypeListView, TableOptions, TableSpec, render_table,
31};
32
33/// Renders output as tab-separated values with a header row.
34pub struct TsvRenderer {
35    out: Box<dyn Write>,
36    /// Auth-state disclosure sink. In production (`new()`), this is
37    /// `io::stderr()`. In tests using `with_writer`, it is `io::sink()` so
38    /// existing tests don't emit to real stderr. Use `with_writers` to capture
39    /// both streams in new tabular-disclosure tests.
40    err: Box<dyn Write>,
41    /// Per-invocation tabular options (column projection, header mode).
42    /// Defaults to all columns, header on — matches today's unflagged behaviour.
43    options: TableOptions,
44}
45
46impl TsvRenderer {
47    /// Creates a renderer writing to stdout (data) and stderr (disclosure).
48    pub fn new() -> Self {
49        Self {
50            out: Box::new(io::stdout()),
51            err: Box::new(io::stderr()),
52            options: TableOptions::default(),
53        }
54    }
55
56    /// Creates a renderer writing data to `w`; discards disclosure (stderr
57    /// becomes `io::sink()`). Keeps all existing tabular tests working without
58    /// emitting to real stderr.
59    pub fn with_writer(w: impl Write + 'static) -> Self {
60        Self {
61            out: Box::new(w),
62            err: Box::new(io::sink()),
63            options: TableOptions::default(),
64        }
65    }
66
67    /// Creates a renderer writing data to `out` and disclosure to `err`.
68    /// Used by tests that assert the stderr auth-state line.
69    pub fn with_writers(out: impl Write + 'static, err: impl Write + 'static) -> Self {
70        Self {
71            out: Box::new(out),
72            err: Box::new(err),
73            options: TableOptions::default(),
74        }
75    }
76
77    /// Returns a new renderer with the given tabular options applied.
78    /// Used by `Format::into_renderer_with_options` and tests.
79    pub fn with_options(mut self, opts: TableOptions) -> Self {
80        self.options = opts;
81        self
82    }
83}
84
85impl Default for TsvRenderer {
86    fn default() -> Self {
87        Self::new()
88    }
89}
90
91impl Renderer for TsvRenderer {
92    fn diagnostic(&mut self, _diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
93        // TSV errors are rendered by main.rs's eprintln handler; empty stdout for errors
94        // keeps pipelines clean (per ADR-0012: lines/csv/tsv write errors to stderr only).
95        Ok(())
96    }
97
98    fn auth_login(
99        &mut self,
100        outcome: &AuthLoginOutcome,
101        _meta: &MetaContext,
102    ) -> Result<(), Diagnostic> {
103        let expires = outcome
104            .expires_at
105            .map(|dt| dt.to_rfc3339())
106            .unwrap_or_default();
107        let rows = vec![vec![
108            outcome.server.clone(),
109            outcome.user.clone(),
110            expires,
111            "login_success".to_string(),
112        ]];
113        let projected = self.options.projected();
114        let spec = TableSpec {
115            all_columns: AUTH_LOGIN_COLUMNS,
116            rows: &rows,
117            default_columns: None,
118            projected,
119            quote: QuoteMode::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. No last_modified — resource-types have none.
397        let rows: Vec<Vec<String>> = view
398            .items
399            .iter()
400            .map(|item| {
401                let label = item.label.as_deref().unwrap_or("").to_string();
402                let is_builtin_str = if item.is_builtin { "true" } else { "false" };
403                vec![
404                    item.name.clone(),
405                    item.iri.clone(),
406                    label,
407                    is_builtin_str.to_string(),
408                ]
409            })
410            .collect();
411        let projected = self.options.projected();
412        let spec = TableSpec {
413            all_columns: RESOURCE_TYPES_COLUMNS,
414            rows: &rows,
415            default_columns: None,
416            projected,
417            quote: QuoteMode::Tsv,
418            header: self.options.header,
419        };
420        render_table(&mut *self.out, &spec)?;
421        render_table_disclosure(&mut *self.err, meta)?;
422        Ok(())
423    }
424
425    fn resource_type_describe(
426        &mut self,
427        detail: &crate::model::ResourceTypeDetail,
428        meta: &MetaContext,
429    ) -> Result<(), Diagnostic> {
430        // TSV format: header row + one row per field. Mirrors CSV shape with tab separator.
431        // Full column set: name, iri, value_type, link_target, cardinality, label,
432        // is_builtin, data_model (8 columns; `iri` at position 1).
433        // Default (unflagged) output uses `RESOURCE_TYPE_DESCRIBE_DEFAULT_COLUMNS` —
434        // the 7-column set without `iri`, byte-identical to the pre-020 tsv output.
435        let rows: Vec<Vec<String>> = detail
436            .fields
437            .iter()
438            .map(|field| {
439                let value_type_str = field.value_type.to_string();
440                let link_target = field.link_target.as_deref().unwrap_or("").to_string();
441                let card_str = field.cardinality.to_string();
442                let label = field.label.as_deref().unwrap_or("").to_string();
443                let is_builtin_str = if field.is_builtin { "true" } else { "false" };
444                let data_model = field.data_model.as_deref().unwrap_or("").to_string();
445                vec![
446                    field.name.clone(),
447                    field.iri.clone(),
448                    value_type_str,
449                    link_target,
450                    card_str,
451                    label,
452                    is_builtin_str.to_string(),
453                    data_model,
454                ]
455            })
456            .collect();
457        let projected = self.options.projected();
458        let spec = TableSpec {
459            all_columns: RESOURCE_TYPE_DESCRIBE_COLUMNS,
460            rows: &rows,
461            default_columns: Some(RESOURCE_TYPE_DESCRIBE_DEFAULT_COLUMNS),
462            projected,
463            quote: QuoteMode::Tsv,
464            header: self.options.header,
465        };
466        render_table(&mut *self.out, &spec)?;
467        render_table_disclosure(&mut *self.err, meta)?;
468        Ok(())
469    }
470
471    fn data_model_structure(
472        &mut self,
473        structure: &DataModelStructure,
474        meta: &MetaContext,
475    ) -> Result<(), Diagnostic> {
476        // TSV format: header row + one row per relation. Mirrors CSV shape with tab separator.
477        // Columns: source, target, kind, field, target_data_model.
478        // Empty cell when field/target_data_model is None.
479        let rows: Vec<Vec<String>> = structure
480            .relations
481            .iter()
482            .map(|rel| {
483                let kind_str = rel.kind.to_string();
484                let field = rel.field.as_deref().unwrap_or("").to_string();
485                let target_dm = rel.target_data_model.as_deref().unwrap_or("").to_string();
486                vec![
487                    rel.source.clone(),
488                    rel.target.clone(),
489                    kind_str,
490                    field,
491                    target_dm,
492                ]
493            })
494            .collect();
495        let projected = self.options.projected();
496        let spec = TableSpec {
497            all_columns: DATA_MODEL_STRUCTURE_COLUMNS,
498            rows: &rows,
499            default_columns: None,
500            projected,
501            quote: QuoteMode::Tsv,
502            header: self.options.header,
503        };
504        render_table(&mut *self.out, &spec)?;
505        render_table_disclosure(&mut *self.err, meta)?;
506        Ok(())
507    }
508
509    fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
510        let rows: Vec<Vec<String>> = view
511            .items
512            .iter()
513            .map(|item| {
514                vec![
515                    item.label.clone(),
516                    item.iri.clone(),
517                    item.ark_url.as_deref().unwrap_or("").to_string(),
518                    item.creation_date.as_deref().unwrap_or("").to_string(),
519                    item.last_modified.as_deref().unwrap_or("").to_string(),
520                    item.resource_type.clone(),
521                ]
522            })
523            .collect();
524        let projected = self.options.projected();
525        let spec = TableSpec {
526            all_columns: RESOURCE_LIST_COLUMNS,
527            rows: &rows,
528            default_columns: None,
529            projected,
530            quote: QuoteMode::Tsv,
531            header: self.options.header,
532        };
533        render_table(&mut *self.out, &spec)?;
534        render_table_disclosure(&mut *self.err, meta)?;
535        Ok(())
536    }
537
538    fn resource_describe(
539        &mut self,
540        detail: &crate::model::ResourceDetail,
541        meta: &MetaContext,
542    ) -> Result<(), Diagnostic> {
543        // TSV format: `detail.values` selects the shape (ADR-0013 D1).
544        // None → header + metadata row (all ten RESOURCE_DESCRIBE_COLUMNS), unchanged.
545        // Some(fields) → header + one row per value (long-format), metadata dropped.
546        match &detail.values {
547            None => {
548                let rows = vec![build_metadata_row(detail)];
549                let projected = self.options.projected();
550                let spec = TableSpec {
551                    all_columns: RESOURCE_DESCRIBE_COLUMNS,
552                    rows: &rows,
553                    default_columns: None,
554                    projected,
555                    quote: QuoteMode::Tsv,
556                    header: self.options.header,
557                };
558                render_table(&mut *self.out, &spec)?;
559            }
560            Some(fields) => {
561                let rows = build_value_rows(&detail.label, &detail.iri, fields);
562                let projected = self.options.projected();
563                let spec = TableSpec {
564                    all_columns: RESOURCE_DESCRIBE_VALUES_COLUMNS,
565                    rows: &rows,
566                    default_columns: Some(RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS),
567                    projected,
568                    quote: QuoteMode::Tsv,
569                    header: self.options.header,
570                };
571                render_table(&mut *self.out, &spec)?;
572            }
573        }
574        render_table_disclosure(&mut *self.err, meta)?;
575        Ok(())
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use crate::model::{
583        DataModel, DataModelDetail, DataModelSummary, Project, ProjectDetail, ProjectStatus,
584        ResourceType, ResourceTypeSummary,
585    };
586    use crate::render::test_support::{SharedBuf, make_meta};
587    use crate::render::{DataModelListView, ResourceTypeListView};
588
589    fn make_fixture() -> Vec<Project> {
590        vec![
591            Project {
592                iri: "http://rdfh.ch/projects/0001".into(),
593                shortcode: "0001".into(),
594                shortname: "anything".into(),
595                longname: Some("Anything Project".into()),
596                status: ProjectStatus::Active,
597                data_models: 2,
598            },
599            Project {
600                iri: "http://rdfh.ch/projects/0002".into(),
601                shortcode: "0002".into(),
602                shortname: "images".into(),
603                longname: None,
604                status: ProjectStatus::Inactive,
605                data_models: 0,
606            },
607            Project {
608                iri: "http://rdfh.ch/projects/0803".into(),
609                shortcode: "0803".into(),
610                shortname: "daschland".into(),
611                longname: Some("DaSCHland Project".into()),
612                status: ProjectStatus::Active,
613                data_models: 1,
614            },
615        ]
616    }
617
618    #[test]
619    fn projects_tsv_output() {
620        let out = SharedBuf::new();
621        let err = SharedBuf::new();
622        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
623        let view = ProjectListView {
624            items: make_fixture(),
625            total: 3,
626            filter: None,
627        };
628        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
629        renderer.projects(&view, &meta).unwrap();
630
631        let stdout = out.string();
632        assert!(stdout.starts_with("shortcode\tshortname\tlongname\tstatus\tdata_models\tiri\n"));
633        assert!(
634            stdout.contains(
635                "0001\tanything\tAnything Project\tactive\t2\thttp://rdfh.ch/projects/0001"
636            )
637        );
638        // None longname → empty (trailing tab before status)
639        assert!(stdout.contains("0002\timages\t\tinactive\t0\thttp://rdfh.ch/projects/0002"));
640        assert!(stdout.contains(
641            "0803\tdaschland\tDaSCHland Project\tactive\t1\thttp://rdfh.ch/projects/0803"
642        ));
643    }
644
645    #[test]
646    fn projects_tsv_stderr_disclosure() {
647        let out = SharedBuf::new();
648        let err = SharedBuf::new();
649        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
650        let view = ProjectListView {
651            items: make_fixture(),
652            total: 3,
653            filter: None,
654        };
655        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
656        renderer.projects(&view, &meta).unwrap();
657
658        let err_str = err.string();
659        assert_eq!(
660            err_str.trim(),
661            "[anonymous on https://api.test.dasch.swiss]"
662        );
663        assert!(!out.string().contains("[anonymous"));
664    }
665
666    #[test]
667    fn auth_status_tsv_stderr_disclosure() {
668        // AuthenticatedViaEnv — the env-vs-cache gap this fix closes
669        let out = SharedBuf::new();
670        let err = SharedBuf::new();
671        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
672        let meta = make_meta(
673            "authenticated via DSP_TOKEN",
674            "https://api.prod.dasch.swiss",
675        );
676        renderer
677            .auth_status(
678                &AuthStatusOutcome::AuthenticatedViaEnv {
679                    server: "https://api.prod.dasch.swiss".to_string(),
680                    expires_at: None,
681                    expired: false,
682                },
683                &meta,
684            )
685            .unwrap();
686        let err_str = err.string();
687        assert_eq!(
688            err_str.trim(),
689            "[authenticated via DSP_TOKEN on https://api.prod.dasch.swiss]"
690        );
691        // Disclosure must not appear on stdout
692        assert!(!out.string().contains('['));
693
694        // NotLoggedIn — disclosure emitted for every outcome
695        let out2 = SharedBuf::new();
696        let err2 = SharedBuf::new();
697        let mut renderer2 = TsvRenderer::with_writers(out2.clone(), err2.clone());
698        let meta2 = make_meta("anonymous", "https://api.prod.dasch.swiss");
699        renderer2
700            .auth_status(
701                &AuthStatusOutcome::NotLoggedIn {
702                    server: "https://api.prod.dasch.swiss".to_string(),
703                },
704                &meta2,
705            )
706            .unwrap();
707        let err2_str = err2.string();
708        assert_eq!(
709            err2_str.trim(),
710            "[anonymous on https://api.prod.dasch.swiss]"
711        );
712        // Disclosure must not appear on stdout (symmetry with the env case above)
713        assert!(!out2.string().contains('['));
714    }
715
716    #[test]
717    fn projects_tsv_with_writer_no_stderr_leak() {
718        let out = SharedBuf::new();
719        let mut renderer = TsvRenderer::with_writer(out.clone());
720        let view = ProjectListView {
721            items: vec![],
722            total: 0,
723            filter: None,
724        };
725        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
726        renderer.projects(&view, &meta).unwrap();
727        assert_eq!(
728            out.string(),
729            "shortcode\tshortname\tlongname\tstatus\tdata_models\tiri\n"
730        );
731    }
732
733    #[test]
734    fn project_describe_tsv_output() {
735        let out = SharedBuf::new();
736        let err = SharedBuf::new();
737        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
738        let detail = ProjectDetail {
739            iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
740            shortcode: "0801".into(),
741            shortname: "beol".into(),
742            longname: Some("Bernoulli-Euler Online".into()),
743            status: ProjectStatus::Active,
744            description: vec![],
745            keywords: vec![],
746            data_models: vec![
747                DataModelSummary {
748                    name: "beol".into(),
749                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
750                },
751                DataModelSummary {
752                    name: "biblio".into(),
753                    iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
754                },
755            ],
756        };
757        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
758        renderer.project_describe(&detail, &meta).unwrap();
759
760        let stdout = out.string();
761        // Header row
762        assert!(stdout.starts_with("shortcode\tshortname\tlongname\tstatus\tdata_models\tiri\n"));
763        // Data row: data_models = count (2)
764        assert!(stdout.contains(
765            "0801\tbeol\tBernoulli-Euler Online\tactive\t2\thttp://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF"
766        ));
767    }
768
769    #[test]
770    fn project_describe_tsv_no_longname() {
771        let out = SharedBuf::new();
772        let mut renderer = TsvRenderer::with_writer(out.clone());
773        let detail = ProjectDetail {
774            iri: "http://rdfh.ch/projects/0000".into(),
775            shortcode: "0000".into(),
776            shortname: "minimal".into(),
777            longname: None,
778            status: ProjectStatus::Inactive,
779            description: vec![],
780            keywords: vec![],
781            data_models: vec![],
782        };
783        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
784        renderer.project_describe(&detail, &meta).unwrap();
785
786        let stdout = out.string();
787        // None longname → empty field (two consecutive tabs)
788        assert!(stdout.contains("0000\tminimal\t\tinactive\t0\thttp://rdfh.ch/projects/0000"));
789    }
790
791    #[test]
792    fn project_describe_tsv_stderr_disclosure() {
793        let out = SharedBuf::new();
794        let err = SharedBuf::new();
795        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
796        let detail = ProjectDetail {
797            iri: "http://rdfh.ch/projects/0001".into(),
798            shortcode: "0001".into(),
799            shortname: "test".into(),
800            longname: None,
801            status: ProjectStatus::Active,
802            description: vec![],
803            keywords: vec![],
804            data_models: vec![],
805        };
806        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
807        renderer.project_describe(&detail, &meta).unwrap();
808
809        let err_str = err.string();
810        assert_eq!(
811            err_str.trim(),
812            "[anonymous on https://api.test.dasch.swiss]"
813        );
814        assert!(!out.string().contains("[anonymous"));
815    }
816
817    fn make_data_model_fixture() -> Vec<DataModel> {
818        vec![
819            DataModel {
820                name: "beol".into(),
821                iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
822                label: Some("The BEOL data-model".into()),
823                last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
824                is_builtin: false,
825            },
826            DataModel {
827                name: "biblio".into(),
828                iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
829                label: None,
830                last_modified: None,
831                is_builtin: false,
832            },
833            DataModel {
834                name: "knora-api".into(),
835                iri: "http://api.knora.org/ontology/knora-api/v2".into(),
836                label: None,
837                last_modified: None,
838                is_builtin: true,
839            },
840        ]
841    }
842
843    #[test]
844    fn data_models_tsv_output() {
845        let out = SharedBuf::new();
846        let err = SharedBuf::new();
847        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
848        let view = DataModelListView {
849            items: make_data_model_fixture(),
850            total: 3,
851            filter: None,
852        };
853        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
854        renderer.data_models(&view, &meta).unwrap();
855
856        let stdout = out.string();
857        // Header row
858        assert!(stdout.starts_with("name\tiri\tlabel\tlast_modified\tis_builtin\n"));
859        // beol: has label and last_modified, not builtin
860        assert!(stdout.contains(
861            "beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\tThe BEOL data-model\t2024-05-27T13:43:26.233048Z\tfalse"
862        ));
863        // biblio: label None → empty, last_modified None → empty (two consecutive tabs)
864        assert!(
865            stdout.contains("biblio\thttp://api.dasch.swiss/ontology/0801/biblio/v2\t\t\tfalse")
866        );
867        // knora-api: builtin → "true"
868        assert!(stdout.contains("knora-api\thttp://api.knora.org/ontology/knora-api/v2\t\t\ttrue"));
869    }
870
871    #[test]
872    fn data_models_tsv_stderr_disclosure() {
873        let out = SharedBuf::new();
874        let err = SharedBuf::new();
875        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
876        let view = DataModelListView {
877            items: make_data_model_fixture(),
878            total: 3,
879            filter: None,
880        };
881        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
882        renderer.data_models(&view, &meta).unwrap();
883
884        let err_str = err.string();
885        assert_eq!(
886            err_str.trim(),
887            "[anonymous on https://api.test.dasch.swiss]"
888        );
889        assert!(!out.string().contains("[anonymous"));
890    }
891
892    // ── data_model_describe TSV tests ─────────────────────────────────────────
893
894    fn make_beol_dm_detail() -> DataModelDetail {
895        DataModelDetail {
896            name: "beol".into(),
897            iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
898            label: Some("The BEOL data-model".into()),
899            last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
900            resource_types: vec![
901                ResourceTypeSummary {
902                    name: "Archive".into(),
903                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
904                    label: Some("Archive".into()),
905                },
906                ResourceTypeSummary {
907                    name: "basicLetter".into(),
908                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
909                    label: None,
910                },
911                ResourceTypeSummary {
912                    name: "letter".into(),
913                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
914                    label: Some("Letter".into()),
915                },
916            ],
917        }
918    }
919
920    #[test]
921    fn data_model_describe_tsv_output() {
922        let out = SharedBuf::new();
923        let err = SharedBuf::new();
924        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
925        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
926        renderer
927            .data_model_describe(&make_beol_dm_detail(), &meta)
928            .unwrap();
929
930        let stdout = out.string();
931        // Header row
932        assert!(
933            stdout.starts_with("name\tiri\tlabel\tlast_modified\tresource_types\n"),
934            "header row missing; got:\n{stdout}"
935        );
936        // Data row: resource_types = count (3)
937        assert!(
938            stdout.contains(
939                "beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\tThe BEOL data-model\t2024-05-27T13:43:26.233048Z\t3"
940            ),
941            "data row missing; got:\n{stdout}"
942        );
943    }
944
945    #[test]
946    fn data_model_describe_tsv_no_label_no_last_modified() {
947        let out = SharedBuf::new();
948        let mut renderer = TsvRenderer::with_writer(out.clone());
949        let detail = DataModelDetail {
950            name: "minimal".into(),
951            iri: "http://api.dasch.swiss/ontology/0000/minimal/v2".into(),
952            label: None,
953            last_modified: None,
954            resource_types: vec![],
955        };
956        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
957        renderer.data_model_describe(&detail, &meta).unwrap();
958
959        let stdout = out.string();
960        // None label → empty, None last_modified → empty (two consecutive tabs)
961        assert!(
962            stdout.contains("minimal\thttp://api.dasch.swiss/ontology/0000/minimal/v2\t\t\t0"),
963            "None → empty fields must be handled; got:\n{stdout}"
964        );
965    }
966
967    #[test]
968    fn data_model_describe_tsv_stderr_disclosure() {
969        let out = SharedBuf::new();
970        let err = SharedBuf::new();
971        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
972        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
973        renderer
974            .data_model_describe(&make_beol_dm_detail(), &meta)
975            .unwrap();
976
977        let err_str = err.string();
978        assert_eq!(
979            err_str.trim(),
980            "[anonymous on https://api.test.dasch.swiss]"
981        );
982        assert!(!out.string().contains("[anonymous"));
983    }
984
985    // ── resource_types TSV tests ──────────────────────────────────────────────
986
987    fn make_resource_type_fixture() -> Vec<ResourceType> {
988        vec![
989            ResourceType {
990                name: "Archive".into(),
991                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
992                label: Some("Archive".into()),
993                is_builtin: false,
994            },
995            ResourceType {
996                name: "letter".into(),
997                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
998                label: None,
999                is_builtin: false,
1000            },
1001        ]
1002    }
1003
1004    #[test]
1005    fn resource_types_tsv_output() {
1006        let out = SharedBuf::new();
1007        let err = SharedBuf::new();
1008        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1009        let view = ResourceTypeListView {
1010            items: make_resource_type_fixture(),
1011            total: 2,
1012            filter: None,
1013            data_model: "beol".into(),
1014        };
1015        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1016        renderer.resource_types(&view, &meta).unwrap();
1017
1018        let stdout = out.string();
1019        // Header row (no last_modified column)
1020        assert!(
1021            stdout.starts_with("name\tiri\tlabel\tis_builtin\n"),
1022            "header row missing; got:\n{stdout}"
1023        );
1024        // Archive: has label, not builtin
1025        assert!(
1026            stdout.contains(
1027                "Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\tArchive\tfalse"
1028            ),
1029            "Archive row missing; got:\n{stdout}"
1030        );
1031        // letter: label None → empty (two consecutive tabs)
1032        assert!(
1033            stdout.contains("letter\thttp://api.dasch.swiss/ontology/0801/beol/v2#letter\t\tfalse"),
1034            "letter row missing; got:\n{stdout}"
1035        );
1036    }
1037
1038    #[test]
1039    fn resource_types_tsv_with_builtins() {
1040        let out = SharedBuf::new();
1041        let mut renderer = TsvRenderer::with_writer(out.clone());
1042        let view = ResourceTypeListView {
1043            items: vec![ResourceType {
1044                name: "Region".into(),
1045                iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1046                label: Some("Region".into()),
1047                is_builtin: true,
1048            }],
1049            total: 1,
1050            filter: None,
1051            data_model: "beol".into(),
1052        };
1053        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1054        renderer.resource_types(&view, &meta).unwrap();
1055
1056        let stdout = out.string();
1057        // built-in row with is_builtin = "true"
1058        assert!(
1059            stdout.contains(
1060                "Region\thttp://api.knora.org/ontology/knora-api/v2#Region\tRegion\ttrue"
1061            ),
1062            "built-in row missing; got:\n{stdout}"
1063        );
1064    }
1065
1066    #[test]
1067    fn resource_types_tsv_with_filter() {
1068        // filter does not affect the TSV output shape; just verify it renders cleanly
1069        let out = SharedBuf::new();
1070        let mut renderer = TsvRenderer::with_writer(out.clone());
1071        let view = ResourceTypeListView {
1072            items: vec![make_resource_type_fixture().remove(0)], // just Archive
1073            total: 2,
1074            filter: Some("arch".to_string()),
1075            data_model: "beol".into(),
1076        };
1077        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1078        renderer.resource_types(&view, &meta).unwrap();
1079
1080        let stdout = out.string();
1081        assert!(stdout.starts_with("name\tiri\tlabel\tis_builtin\n"));
1082        assert!(stdout.contains("Archive"));
1083    }
1084
1085    #[test]
1086    fn resource_types_tsv_empty() {
1087        let out = SharedBuf::new();
1088        let mut renderer = TsvRenderer::with_writer(out.clone());
1089        let view = ResourceTypeListView {
1090            items: vec![],
1091            total: 0,
1092            filter: None,
1093            data_model: "beol".into(),
1094        };
1095        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1096        renderer.resource_types(&view, &meta).unwrap();
1097
1098        // Only header row
1099        assert_eq!(out.string(), "name\tiri\tlabel\tis_builtin\n");
1100    }
1101
1102    #[test]
1103    fn resource_types_tsv_stderr_disclosure() {
1104        let out = SharedBuf::new();
1105        let err = SharedBuf::new();
1106        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1107        let view = ResourceTypeListView {
1108            items: make_resource_type_fixture(),
1109            total: 2,
1110            filter: None,
1111            data_model: "beol".into(),
1112        };
1113        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1114        renderer.resource_types(&view, &meta).unwrap();
1115
1116        let err_str = err.string();
1117        assert_eq!(
1118            err_str.trim(),
1119            "[anonymous on https://api.test.dasch.swiss]"
1120        );
1121        assert!(!out.string().contains("[anonymous"));
1122    }
1123
1124    // ── resource_describe tsv values-note tests ───────────────────────────────
1125
1126    use crate::model::{
1127        FieldValues, ResourceAccess, ResourceDetail, ResourceVisibility, ValueContent,
1128    };
1129
1130    fn make_resource_detail(values: Option<Vec<FieldValues>>) -> ResourceDetail {
1131        ResourceDetail {
1132            label: "Test Resource".into(),
1133            iri: "http://rdfh.ch/0803/abc123".into(),
1134            resource_type: "Page".into(),
1135            ark_url: None,
1136            creation_date: None,
1137            last_modified: None,
1138            attached_project: None,
1139            owner: None,
1140            visibility: Some(ResourceVisibility::Public),
1141            your_access: Some(ResourceAccess::View),
1142            values,
1143        }
1144    }
1145
1146    #[test]
1147    fn resource_describe_tsv_some_values_no_note_renders_value_rows() {
1148        // When values is Some, no stderr note; stdout carries the values header +
1149        // one row per value in the compact default columns. Metadata (label/iri)
1150        // is dropped by default in values mode.
1151        let out = SharedBuf::new();
1152        let err = SharedBuf::new();
1153        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1154        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1155        renderer
1156            .resource_describe(
1157                &make_resource_detail(Some(vec![FieldValues {
1158                    name: "hasTitle".into(),
1159                    label: None,
1160                    values: vec![ValueContent::Text("Hello".into()).into()],
1161                }])),
1162                &meta,
1163            )
1164            .unwrap();
1165
1166        let err_str = err.string();
1167        assert!(
1168            !err_str.contains("values not shown"),
1169            "no values note must be emitted when values is Some; got:\n{err_str}"
1170        );
1171        let stdout = out.string();
1172        assert!(
1173            stdout.starts_with("field\tfield_label\tvalue_type\tvalue\n"),
1174            "tsv values header must be the compact default set; got:\n{stdout}"
1175        );
1176        assert!(
1177            stdout.contains("hasTitle\t\ttext\tHello\n"),
1178            "expected value row for hasTitle; got:\n{stdout}"
1179        );
1180        // Metadata is dropped in values mode by default.
1181        assert!(
1182            !stdout.contains("Test Resource"),
1183            "metadata row must not appear in values mode; got:\n{stdout}"
1184        );
1185    }
1186
1187    #[test]
1188    fn resource_describe_tsv_no_values_no_note() {
1189        // When values is None, no stderr note.
1190        let out = SharedBuf::new();
1191        let err = SharedBuf::new();
1192        let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1193        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1194        renderer
1195            .resource_describe(&make_resource_detail(None), &meta)
1196            .unwrap();
1197
1198        assert!(
1199            !err.string().contains("values not shown"),
1200            "no note expected when values is None; got:\n{}",
1201            err.string()
1202        );
1203    }
1204}