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