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