Skip to main content

dsp_cli/render/
lines.rs

1//! Lines renderer — one identifier per line.
2//!
3//! Lines output emits one identifier per line for easy shell pipeline
4//! consumption (e.g. piping into `xargs`). Auth methods emit:
5//! - `auth_login` / `auth_status` / `auth_set_token`: the server URL.
6//! - `auth_logout`: `<server>\twas_cached=true|false` (tab-separated,
7//!   bespoke decorated default; projected path goes through the engine).
8//!
9//! Per-noun methods arrive with real data: `project_dump` in Phase 3,
10//! `projects` (list) in Phase 4; other noun-groups (data-models, etc.) in Phase 5.
11
12use std::io::{self, Write};
13
14use crate::diagnostic::Diagnostic;
15use crate::model::{DataModelDetail, DataModelStructure, ProjectDetail};
16use crate::render::auth::{
17    AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome,
18};
19use crate::render::dump::{DumpDeleteOutcome, DumpOutcome};
20use crate::render::table::render_table_disclosure;
21use crate::render::value::{build_metadata_row, build_value_rows};
22use crate::render::{
23    AUTH_LOGIN_COLUMNS, AUTH_LOGOUT_COLUMNS, DATA_MODEL_DESCRIBE_COLUMNS,
24    DATA_MODEL_STRUCTURE_COLUMNS, DATA_MODELS_COLUMNS, DataModelListView, HeaderMode, MetaContext,
25    PROJECT_DUMP_COLUMNS, PROJECT_DUMP_DELETED_COLUMNS, PROJECTS_COLUMNS, ProjectListView,
26    QuoteMode, RESOURCE_DESCRIBE_COLUMNS, RESOURCE_DESCRIBE_VALUES_COLUMNS,
27    RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS, RESOURCE_LIST_COLUMNS,
28    RESOURCE_TYPE_DESCRIBE_COLUMNS, RESOURCE_TYPES_COLUMNS, Renderer, ResourceListView,
29    ResourceTypeListView, TableOptions, TableSpec, render_table,
30};
31use crate::util::text::replace_control_chars;
32
33/// Renders output as one identifier per line.
34pub struct LinesRenderer {
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).
42    /// Defaults to all columns selected from the lean default subset — matches
43    /// today's unflagged lean behaviour.
44    options: TableOptions,
45}
46
47impl LinesRenderer {
48    /// Creates a renderer writing to stdout (data) and stderr (disclosure).
49    pub fn new() -> Self {
50        Self {
51            out: Box::new(io::stdout()),
52            err: Box::new(io::stderr()),
53            options: TableOptions::default(),
54        }
55    }
56
57    /// Creates a renderer writing data to `w`; discards disclosure (stderr
58    /// becomes `io::sink()`). Keeps all existing tabular tests working without
59    /// emitting to real stderr.
60    pub fn with_writer(w: impl Write + 'static) -> Self {
61        Self {
62            out: Box::new(w),
63            err: Box::new(io::sink()),
64            options: TableOptions::default(),
65        }
66    }
67
68    /// Creates a renderer writing data to `out` and disclosure to `err`.
69    /// Used by tests that assert the stderr auth-state line.
70    pub fn with_writers(out: impl Write + 'static, err: impl Write + 'static) -> Self {
71        Self {
72            out: Box::new(out),
73            err: Box::new(err),
74            options: TableOptions::default(),
75        }
76    }
77
78    /// Returns a new renderer with the given tabular options applied.
79    /// Used by `Format::into_renderer_with_options` and tests.
80    pub fn with_options(mut self, opts: TableOptions) -> Self {
81        self.options = opts;
82        self
83    }
84}
85
86impl Default for LinesRenderer {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92impl Renderer for LinesRenderer {
93    fn diagnostic(&mut self, _diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
94        // Lines errors are rendered by main.rs's eprintln handler; empty stdout for errors
95        // keeps pipelines clean (per ADR-0012: lines/csv/tsv write errors to stderr only).
96        Ok(())
97    }
98
99    fn auth_login(
100        &mut self,
101        outcome: &AuthLoginOutcome,
102        _meta: &MetaContext,
103    ) -> Result<(), Diagnostic> {
104        let expires = outcome
105            .expires_at
106            .map(|dt| dt.to_rfc3339())
107            .unwrap_or_default();
108        let rows = vec![vec![
109            outcome.server.clone(),
110            outcome.user.clone(),
111            expires,
112            "login_success".to_string(),
113        ]];
114        let projected = self.options.projected();
115        let spec = TableSpec {
116            all_columns: AUTH_LOGIN_COLUMNS,
117            rows: &rows,
118            default_columns: Some(&["server"]),
119            projected,
120            quote: QuoteMode::Lines,
121            header: HeaderMode::Off,
122        };
123        render_table(&mut *self.out, &spec)
124    }
125
126    fn auth_status(
127        &mut self,
128        outcome: &AuthStatusOutcome,
129        meta: &MetaContext,
130    ) -> Result<(), Diagnostic> {
131        let row = match outcome {
132            AuthStatusOutcome::LoggedIn {
133                server,
134                user,
135                expires_at,
136                expired,
137            } => {
138                let user_str = user.as_deref().unwrap_or("").to_string();
139                let expires = expires_at.map(|dt| dt.to_rfc3339()).unwrap_or_default();
140                let state = if *expired { "expired" } else { "logged_in" };
141                vec![server.clone(), user_str, expires, state.to_string()]
142            }
143            // DSP_TOKEN env-override: row shape is uniform with LoggedIn (user empty,
144            // expires_at rfc3339 or empty, state "logged_in"|"expired"). The source
145            // is never a stdout column.
146            AuthStatusOutcome::AuthenticatedViaEnv {
147                server,
148                expires_at,
149                expired,
150            } => {
151                let expires = expires_at.map(|dt| dt.to_rfc3339()).unwrap_or_default();
152                let state = if *expired { "expired" } else { "logged_in" };
153                vec![server.clone(), String::new(), expires, state.to_string()]
154            }
155            AuthStatusOutcome::NotLoggedIn { server } => {
156                vec![
157                    server.clone(),
158                    String::new(),
159                    String::new(),
160                    "not_logged_in".to_string(),
161                ]
162            }
163        };
164        let rows = vec![row];
165        let projected = self.options.projected();
166        let spec = TableSpec {
167            all_columns: AUTH_LOGIN_COLUMNS,
168            rows: &rows,
169            default_columns: Some(&["server"]),
170            projected,
171            quote: QuoteMode::Lines,
172            header: HeaderMode::Off,
173        };
174        render_table(&mut *self.out, &spec)?;
175        render_table_disclosure(&mut *self.err, meta)?;
176        Ok(())
177    }
178
179    fn auth_logout(
180        &mut self,
181        outcome: &AuthLogoutOutcome,
182        _meta: &MetaContext,
183    ) -> Result<(), Diagnostic> {
184        // Sanctioned engine bypass (plan 020 D6): the lines default emits the
185        // decorated literal `<server>\twas_cached=true|false`, which the engine
186        // cannot reproduce.  When --columns is active (projection path), plain
187        // values go through the engine with replace_control_chars applied.
188        //
189        // The server field is passed through replace_control_chars even on the
190        // bespoke path — byte-identical for all normal server URLs (which
191        // contain no ASCII control chars), but correct-by-construction
192        // regardless of what the server string contains.
193        if self.options.columns.is_none() {
194            // Default (decorated) path — bespoke writeln!.
195            writeln!(
196                self.out,
197                "{}\twas_cached={}",
198                replace_control_chars(&outcome.server),
199                outcome.was_cached
200            )?;
201        } else {
202            // Projected path — engine with plain was_cached value ("true"/"false").
203            let rows = vec![vec![outcome.server.clone(), outcome.was_cached.to_string()]];
204            let projected = self.options.projected();
205            let spec = TableSpec {
206                all_columns: AUTH_LOGOUT_COLUMNS,
207                rows: &rows,
208                default_columns: None,
209                projected,
210                quote: QuoteMode::Lines,
211                header: HeaderMode::Off,
212            };
213            render_table(&mut *self.out, &spec)?;
214        }
215        Ok(())
216    }
217
218    fn auth_set_token(
219        &mut self,
220        outcome: &AuthSetTokenOutcome,
221        _meta: &MetaContext,
222    ) -> Result<(), Diagnostic> {
223        let user_str = outcome.user.as_deref().unwrap_or("").to_string();
224        let expires = outcome
225            .expires_at
226            .map(|dt| dt.to_rfc3339())
227            .unwrap_or_default();
228        let rows = vec![vec![
229            outcome.server.clone(),
230            user_str,
231            expires,
232            "token_cached".to_string(),
233        ]];
234        let projected = self.options.projected();
235        let spec = TableSpec {
236            all_columns: AUTH_LOGIN_COLUMNS,
237            rows: &rows,
238            default_columns: Some(&["server"]),
239            projected,
240            quote: QuoteMode::Lines,
241            header: HeaderMode::Off,
242        };
243        render_table(&mut *self.out, &spec)
244    }
245
246    fn project_dump(
247        &mut self,
248        outcome: &DumpOutcome,
249        _meta: &MetaContext,
250    ) -> Result<(), Diagnostic> {
251        // Lines format: bare path only; cleanup disclosure is omitted (prose/json only).
252        // reused/created_at are also prose/json only.
253        let rows = vec![vec![outcome.path.display().to_string()]];
254        let projected = self.options.projected();
255        let spec = TableSpec {
256            all_columns: PROJECT_DUMP_COLUMNS,
257            rows: &rows,
258            default_columns: Some(&["path"]),
259            projected,
260            quote: QuoteMode::Lines,
261            header: HeaderMode::Off,
262        };
263        render_table(&mut *self.out, &spec)
264    }
265
266    fn project_dump_deleted(
267        &mut self,
268        outcome: &DumpDeleteOutcome,
269        _meta: &MetaContext,
270    ) -> Result<(), Diagnostic> {
271        // Lines format: one token — "deleted" when true, "not-deleted" otherwise.
272        // Note: csv builds true/false for the same `deleted` column; lines builds
273        // "deleted"/"not-deleted". Each renderer owns its own row values, so this
274        // divergence is acceptable — it preserves byte-identity for both formats'
275        // existing snapshots.
276        let token = if outcome.deleted {
277            "deleted".to_string()
278        } else {
279            "not-deleted".to_string()
280        };
281        let rows = vec![vec![token]];
282        let projected = self.options.projected();
283        let spec = TableSpec {
284            all_columns: PROJECT_DUMP_DELETED_COLUMNS,
285            rows: &rows,
286            default_columns: Some(&["deleted"]),
287            projected,
288            quote: QuoteMode::Lines,
289            header: HeaderMode::Off,
290        };
291        render_table(&mut *self.out, &spec)
292    }
293
294    fn projects(&mut self, view: &ProjectListView, meta: &MetaContext) -> Result<(), Diagnostic> {
295        // Lines format: shortcode TAB shortname TAB longname per item, no header.
296        // longname None → empty string (yields a trailing tab — locked by fixture).
297        let rows: Vec<Vec<String>> = view
298            .items
299            .iter()
300            .map(|item| {
301                let longname = item.longname.as_deref().unwrap_or("").to_string();
302                let data_models_str = item.data_models.to_string();
303                vec![
304                    item.shortcode.clone(),
305                    item.shortname.clone(),
306                    longname,
307                    item.status.as_str().to_string(),
308                    data_models_str,
309                    item.iri.clone(),
310                ]
311            })
312            .collect();
313        let projected = self.options.projected();
314        let spec = TableSpec {
315            all_columns: PROJECTS_COLUMNS,
316            rows: &rows,
317            default_columns: Some(&["shortcode", "shortname", "longname"]),
318            projected,
319            quote: QuoteMode::Lines,
320            header: HeaderMode::Off,
321        };
322        render_table(&mut *self.out, &spec)?;
323        render_table_disclosure(&mut *self.err, meta)?;
324        Ok(())
325    }
326
327    fn project_describe(
328        &mut self,
329        project: &ProjectDetail,
330        meta: &MetaContext,
331    ) -> Result<(), Diagnostic> {
332        // Lines format: single row shortcode TAB shortname TAB longname, no header.
333        // longname None → empty string (trailing tab — mirrors `projects` lines behaviour).
334        let longname = project.longname.as_deref().unwrap_or("").to_string();
335        let data_models_str = project.data_models.len().to_string();
336        let rows = vec![vec![
337            project.shortcode.clone(),
338            project.shortname.clone(),
339            longname,
340            project.status.as_str().to_string(),
341            data_models_str,
342            project.iri.clone(),
343        ]];
344        let projected = self.options.projected();
345        let spec = TableSpec {
346            all_columns: PROJECTS_COLUMNS,
347            rows: &rows,
348            default_columns: Some(&["shortcode", "shortname", "longname"]),
349            projected,
350            quote: QuoteMode::Lines,
351            header: HeaderMode::Off,
352        };
353        render_table(&mut *self.out, &spec)?;
354        render_table_disclosure(&mut *self.err, meta)?;
355        Ok(())
356    }
357
358    fn data_models(
359        &mut self,
360        view: &DataModelListView,
361        meta: &MetaContext,
362    ) -> Result<(), Diagnostic> {
363        // Lines format: name TAB iri per item, no header.
364        // Deliberately omits label/last_modified/is_builtin — lean chaining format.
365        let rows: Vec<Vec<String>> = view
366            .items
367            .iter()
368            .map(|item| {
369                let label = item.label.as_deref().unwrap_or("").to_string();
370                let last_modified = item.last_modified.as_deref().unwrap_or("").to_string();
371                let is_builtin_str = if item.is_builtin { "true" } else { "false" };
372                vec![
373                    item.name.clone(),
374                    item.iri.clone(),
375                    label,
376                    last_modified,
377                    is_builtin_str.to_string(),
378                ]
379            })
380            .collect();
381        let projected = self.options.projected();
382        let spec = TableSpec {
383            all_columns: DATA_MODELS_COLUMNS,
384            rows: &rows,
385            default_columns: Some(&["name", "iri"]),
386            projected,
387            quote: QuoteMode::Lines,
388            header: HeaderMode::Off,
389        };
390        render_table(&mut *self.out, &spec)?;
391        render_table_disclosure(&mut *self.err, meta)?;
392        Ok(())
393    }
394
395    fn data_model_describe(
396        &mut self,
397        detail: &DataModelDetail,
398        meta: &MetaContext,
399    ) -> Result<(), Diagnostic> {
400        // Lines format: single row name TAB iri, no header.
401        // Resource-types are omitted — lean chaining format (mirrors project_describe lines).
402        let label = detail.label.as_deref().unwrap_or("").to_string();
403        let last_modified = detail.last_modified.as_deref().unwrap_or("").to_string();
404        let resource_types_str = detail.resource_types.len().to_string();
405        let rows = vec![vec![
406            detail.name.clone(),
407            detail.iri.clone(),
408            label,
409            last_modified,
410            resource_types_str,
411        ]];
412        let projected = self.options.projected();
413        let spec = TableSpec {
414            all_columns: DATA_MODEL_DESCRIBE_COLUMNS,
415            rows: &rows,
416            default_columns: Some(&["name", "iri"]),
417            projected,
418            quote: QuoteMode::Lines,
419            header: HeaderMode::Off,
420        };
421        render_table(&mut *self.out, &spec)?;
422        render_table_disclosure(&mut *self.err, meta)?;
423        Ok(())
424    }
425
426    fn resource_types(
427        &mut self,
428        view: &ResourceTypeListView,
429        meta: &MetaContext,
430    ) -> Result<(), Diagnostic> {
431        // Lines format: name TAB iri per item, no header.
432        // Deliberately omits label/is_builtin — lean chaining format (mirrors data_models).
433        let rows: Vec<Vec<String>> = view
434            .items
435            .iter()
436            .map(|item| {
437                let label = item.label.as_deref().unwrap_or("").to_string();
438                let is_builtin_str = if item.is_builtin { "true" } else { "false" };
439                vec![
440                    item.name.clone(),
441                    item.iri.clone(),
442                    label,
443                    is_builtin_str.to_string(),
444                ]
445            })
446            .collect();
447        let projected = self.options.projected();
448        let spec = TableSpec {
449            all_columns: RESOURCE_TYPES_COLUMNS,
450            rows: &rows,
451            default_columns: Some(&["name", "iri"]),
452            projected,
453            quote: QuoteMode::Lines,
454            header: HeaderMode::Off,
455        };
456        render_table(&mut *self.out, &spec)?;
457        render_table_disclosure(&mut *self.err, meta)?;
458        Ok(())
459    }
460
461    fn resource_type_describe(
462        &mut self,
463        detail: &crate::model::ResourceTypeDetail,
464        meta: &MetaContext,
465    ) -> Result<(), Diagnostic> {
466        // Lines format: one row per field — name TAB iri, no header.
467        // Mirrors data_model_describe lines (single identifier rows, lean chaining).
468        //
469        // Uses the shared RESOURCE_TYPE_DESCRIBE_COLUMNS (which now includes `iri`
470        // at position 1), with `default_columns: Some(&["name", "iri"])` for the
471        // lean path. csv/tsv use the same const with their own default_columns that
472        // omit `iri` from the unflagged output (byte-identical to pre-020 csv/tsv).
473        let rows: Vec<Vec<String>> = detail
474            .fields
475            .iter()
476            .map(|field| {
477                let value_type_str = field.value_type.to_string();
478                let link_target = field.link_target.as_deref().unwrap_or("").to_string();
479                let card_str = field.cardinality.to_string();
480                let label = field.label.as_deref().unwrap_or("").to_string();
481                let is_builtin_str = if field.is_builtin { "true" } else { "false" };
482                let data_model = field.data_model.as_deref().unwrap_or("").to_string();
483                vec![
484                    field.name.clone(),
485                    field.iri.clone(),
486                    value_type_str,
487                    link_target,
488                    card_str,
489                    label,
490                    is_builtin_str.to_string(),
491                    data_model,
492                ]
493            })
494            .collect();
495        let projected = self.options.projected();
496        let spec = TableSpec {
497            all_columns: RESOURCE_TYPE_DESCRIBE_COLUMNS,
498            rows: &rows,
499            default_columns: Some(&["name", "iri"]),
500            projected,
501            quote: QuoteMode::Lines,
502            header: HeaderMode::Off,
503        };
504        render_table(&mut *self.out, &spec)?;
505        render_table_disclosure(&mut *self.err, meta)?;
506        Ok(())
507    }
508
509    fn data_model_structure(
510        &mut self,
511        structure: &DataModelStructure,
512        meta: &MetaContext,
513    ) -> Result<(), Diagnostic> {
514        // Lines format: one relation per line, tab-separated: source TAB target TAB kind TAB field.
515        // field is empty for inherits relations. target_data_model is intentionally omitted
516        // (lean pipe-friendly format — use csv/tsv/json for the full column set). No header.
517        let rows: Vec<Vec<String>> = structure
518            .relations
519            .iter()
520            .map(|rel| {
521                let kind_str = rel.kind.to_string();
522                let field = rel.field.as_deref().unwrap_or("").to_string();
523                let target_dm = rel.target_data_model.as_deref().unwrap_or("").to_string();
524                vec![
525                    rel.source.clone(),
526                    rel.target.clone(),
527                    kind_str,
528                    field,
529                    target_dm,
530                ]
531            })
532            .collect();
533        let projected = self.options.projected();
534        let spec = TableSpec {
535            all_columns: DATA_MODEL_STRUCTURE_COLUMNS,
536            rows: &rows,
537            default_columns: Some(&["source", "target", "kind", "field"]),
538            projected,
539            quote: QuoteMode::Lines,
540            header: HeaderMode::Off,
541        };
542        render_table(&mut *self.out, &spec)?;
543        render_table_disclosure(&mut *self.err, meta)?;
544        Ok(())
545    }
546
547    fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
548        let rows: Vec<Vec<String>> = view
549            .items
550            .iter()
551            .map(|item| {
552                vec![
553                    item.label.clone(),
554                    item.iri.clone(),
555                    item.ark_url.as_deref().unwrap_or("").to_string(),
556                    item.creation_date.as_deref().unwrap_or("").to_string(),
557                    item.last_modified.as_deref().unwrap_or("").to_string(),
558                    item.resource_type.clone(),
559                ]
560            })
561            .collect();
562        let projected = self.options.projected();
563        let spec = TableSpec {
564            all_columns: RESOURCE_LIST_COLUMNS,
565            rows: &rows,
566            default_columns: None,
567            projected,
568            quote: QuoteMode::Lines,
569            header: HeaderMode::Off,
570        };
571        render_table(&mut *self.out, &spec)?;
572        render_table_disclosure(&mut *self.err, meta)?;
573        Ok(())
574    }
575
576    fn resource_describe(
577        &mut self,
578        detail: &crate::model::ResourceDetail,
579        meta: &MetaContext,
580    ) -> Result<(), Diagnostic> {
581        // Lines format: `detail.values` selects the shape (ADR-0013 D1).
582        // None → the metadata row (single row, label TAB iri lean default), unchanged.
583        // Some(fields) → one row per value (long-format), metadata row dropped.
584        match &detail.values {
585            None => {
586                let rows = vec![build_metadata_row(detail)];
587                let projected = self.options.projected();
588                let spec = TableSpec {
589                    all_columns: RESOURCE_DESCRIBE_COLUMNS,
590                    rows: &rows,
591                    default_columns: Some(&["label", "iri"]),
592                    projected,
593                    quote: QuoteMode::Lines,
594                    header: HeaderMode::Off,
595                };
596                render_table(&mut *self.out, &spec)?;
597            }
598            Some(fields) => {
599                let rows = build_value_rows(&detail.label, &detail.iri, fields);
600                let projected = self.options.projected();
601                let spec = TableSpec {
602                    all_columns: RESOURCE_DESCRIBE_VALUES_COLUMNS,
603                    rows: &rows,
604                    default_columns: Some(RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS),
605                    projected,
606                    quote: QuoteMode::Lines,
607                    header: HeaderMode::Off,
608                };
609                render_table(&mut *self.out, &spec)?;
610            }
611        }
612        render_table_disclosure(&mut *self.err, meta)?;
613        Ok(())
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use crate::model::{
621        DataModel, DataModelDetail, Project, ProjectDetail, ProjectStatus, ResourceType,
622        ResourceTypeSummary,
623    };
624    use crate::render::test_support::{SharedBuf, make_meta};
625    use crate::render::{DataModelListView, ResourceTypeListView};
626
627    fn make_fixture() -> Vec<Project> {
628        vec![
629            Project {
630                iri: "http://rdfh.ch/projects/0001".into(),
631                shortcode: "0001".into(),
632                shortname: "anything".into(),
633                longname: Some("Anything Project".into()),
634                status: ProjectStatus::Active,
635                data_models: 2,
636            },
637            Project {
638                iri: "http://rdfh.ch/projects/0002".into(),
639                shortcode: "0002".into(),
640                shortname: "images".into(),
641                longname: None,
642                status: ProjectStatus::Inactive,
643                data_models: 0,
644            },
645            Project {
646                iri: "http://rdfh.ch/projects/0803".into(),
647                shortcode: "0803".into(),
648                shortname: "daschland".into(),
649                longname: Some("DaSCHland Project".into()),
650                status: ProjectStatus::Active,
651                data_models: 1,
652            },
653        ]
654    }
655
656    #[test]
657    fn projects_lines_output() {
658        let out = SharedBuf::new();
659        let err = SharedBuf::new();
660        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
661        let view = ProjectListView {
662            items: make_fixture(),
663            total: 3,
664            filter: None,
665        };
666        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
667        renderer.projects(&view, &meta).unwrap();
668
669        let stdout = out.string();
670        // No header
671        assert!(!stdout.starts_with("shortcode"));
672        // Three rows: shortcode TAB shortname TAB longname
673        assert!(stdout.contains("0001\tanything\tAnything Project\n"));
674        // None longname → trailing tab (empty third field)
675        assert!(stdout.contains("0002\timages\t\n"));
676        assert!(stdout.contains("0803\tdaschland\tDaSCHland Project\n"));
677    }
678
679    #[test]
680    fn projects_lines_stderr_disclosure() {
681        let out = SharedBuf::new();
682        let err = SharedBuf::new();
683        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
684        let view = ProjectListView {
685            items: make_fixture(),
686            total: 3,
687            filter: None,
688        };
689        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
690        renderer.projects(&view, &meta).unwrap();
691
692        let err_str = err.string();
693        assert_eq!(
694            err_str.trim(),
695            "[anonymous on https://api.test.dasch.swiss]"
696        );
697        assert!(!out.string().contains("[anonymous"));
698    }
699
700    #[test]
701    fn auth_status_lines_stderr_disclosure() {
702        // AuthenticatedViaEnv — the env-vs-cache gap this fix closes
703        let out = SharedBuf::new();
704        let err = SharedBuf::new();
705        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
706        let meta = make_meta(
707            "authenticated via DSP_TOKEN",
708            "https://api.prod.dasch.swiss",
709        );
710        renderer
711            .auth_status(
712                &AuthStatusOutcome::AuthenticatedViaEnv {
713                    server: "https://api.prod.dasch.swiss".to_string(),
714                    expires_at: None,
715                    expired: false,
716                },
717                &meta,
718            )
719            .unwrap();
720        let err_str = err.string();
721        assert_eq!(
722            err_str.trim(),
723            "[authenticated via DSP_TOKEN on https://api.prod.dasch.swiss]"
724        );
725        // Disclosure must not appear on stdout
726        assert!(!out.string().contains('['));
727
728        // NotLoggedIn — disclosure emitted for every outcome
729        let out2 = SharedBuf::new();
730        let err2 = SharedBuf::new();
731        let mut renderer2 = LinesRenderer::with_writers(out2.clone(), err2.clone());
732        let meta2 = make_meta("anonymous", "https://api.prod.dasch.swiss");
733        renderer2
734            .auth_status(
735                &AuthStatusOutcome::NotLoggedIn {
736                    server: "https://api.prod.dasch.swiss".to_string(),
737                },
738                &meta2,
739            )
740            .unwrap();
741        let err2_str = err2.string();
742        assert_eq!(
743            err2_str.trim(),
744            "[anonymous on https://api.prod.dasch.swiss]"
745        );
746        // Disclosure must not appear on stdout (symmetry with the env case above)
747        assert!(!out2.string().contains('['));
748    }
749
750    #[test]
751    fn projects_lines_with_writer_no_stderr_leak() {
752        let out = SharedBuf::new();
753        let mut renderer = LinesRenderer::with_writer(out.clone());
754        let view = ProjectListView {
755            items: vec![],
756            total: 0,
757            filter: None,
758        };
759        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
760        renderer.projects(&view, &meta).unwrap();
761        // Empty list → no data rows; disclosure discarded to sink
762        assert_eq!(out.string(), "");
763    }
764
765    #[test]
766    fn project_describe_lines_output() {
767        let out = SharedBuf::new();
768        let err = SharedBuf::new();
769        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
770        let detail = ProjectDetail {
771            iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
772            shortcode: "0801".into(),
773            shortname: "beol".into(),
774            longname: Some("Bernoulli-Euler Online".into()),
775            status: ProjectStatus::Active,
776            description: vec![],
777            keywords: vec![],
778            data_models: vec![],
779        };
780        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
781        renderer.project_describe(&detail, &meta).unwrap();
782
783        let stdout = out.string();
784        // Single row: shortcode TAB shortname TAB longname
785        assert!(stdout.contains("0801\tbeol\tBernoulli-Euler Online\n"));
786        // No header
787        assert!(!stdout.starts_with("shortcode"));
788    }
789
790    #[test]
791    fn project_describe_lines_no_longname_trailing_tab() {
792        let out = SharedBuf::new();
793        let err = SharedBuf::new();
794        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
795        let detail = ProjectDetail {
796            iri: "http://rdfh.ch/projects/0000".into(),
797            shortcode: "0000".into(),
798            shortname: "minimal".into(),
799            longname: None,
800            status: ProjectStatus::Inactive,
801            description: vec![],
802            keywords: vec![],
803            data_models: vec![],
804        };
805        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
806        renderer.project_describe(&detail, &meta).unwrap();
807
808        let stdout = out.string();
809        // None longname → trailing tab (empty third field)
810        assert!(stdout.contains("0000\tminimal\t\n"));
811    }
812
813    #[test]
814    fn project_describe_lines_stderr_disclosure() {
815        let out = SharedBuf::new();
816        let err = SharedBuf::new();
817        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
818        let detail = ProjectDetail {
819            iri: "http://rdfh.ch/projects/0001".into(),
820            shortcode: "0001".into(),
821            shortname: "test".into(),
822            longname: None,
823            status: ProjectStatus::Active,
824            description: vec![],
825            keywords: vec![],
826            data_models: vec![],
827        };
828        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
829        renderer.project_describe(&detail, &meta).unwrap();
830
831        let err_str = err.string();
832        assert_eq!(
833            err_str.trim(),
834            "[anonymous on https://api.test.dasch.swiss]"
835        );
836        // Disclosure must not appear on stdout
837        assert!(!out.string().contains("[anonymous"));
838    }
839
840    fn make_data_model_fixture() -> Vec<DataModel> {
841        vec![
842            DataModel {
843                name: "beol".into(),
844                iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
845                label: Some("The BEOL data-model".into()),
846                last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
847                is_builtin: false,
848            },
849            DataModel {
850                name: "biblio".into(),
851                iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
852                label: None,
853                last_modified: None,
854                is_builtin: false,
855            },
856            DataModel {
857                name: "knora-api".into(),
858                iri: "http://api.knora.org/ontology/knora-api/v2".into(),
859                label: None,
860                last_modified: None,
861                is_builtin: true,
862            },
863        ]
864    }
865
866    #[test]
867    fn data_models_lines_output() {
868        let out = SharedBuf::new();
869        let err = SharedBuf::new();
870        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
871        let view = DataModelListView {
872            items: make_data_model_fixture(),
873            total: 3,
874            filter: None,
875        };
876        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
877        renderer.data_models(&view, &meta).unwrap();
878
879        let stdout = out.string();
880        // No header
881        assert!(!stdout.starts_with("name"));
882        // name TAB iri rows
883        assert!(stdout.contains("beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\n"));
884        assert!(stdout.contains("biblio\thttp://api.dasch.swiss/ontology/0801/biblio/v2\n"));
885        assert!(stdout.contains("knora-api\thttp://api.knora.org/ontology/knora-api/v2\n"));
886        // label/last_modified/is_builtin are NOT in lines output
887        assert!(!stdout.contains("The BEOL"));
888        assert!(!stdout.contains("2024-05-27"));
889        assert!(!stdout.contains("true"));
890    }
891
892    #[test]
893    fn data_models_lines_stderr_disclosure() {
894        let out = SharedBuf::new();
895        let err = SharedBuf::new();
896        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
897        let view = DataModelListView {
898            items: make_data_model_fixture(),
899            total: 3,
900            filter: None,
901        };
902        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
903        renderer.data_models(&view, &meta).unwrap();
904
905        let err_str = err.string();
906        assert_eq!(
907            err_str.trim(),
908            "[anonymous on https://api.test.dasch.swiss]"
909        );
910        // Disclosure must not appear on stdout
911        assert!(!out.string().contains("[anonymous"));
912    }
913
914    // ── data_model_describe lines tests ──────────────────────────────────────
915
916    fn make_beol_dm_detail() -> DataModelDetail {
917        DataModelDetail {
918            name: "beol".into(),
919            iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
920            label: Some("The BEOL data-model".into()),
921            last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
922            resource_types: vec![
923                ResourceTypeSummary {
924                    name: "Archive".into(),
925                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
926                    label: Some("Archive".into()),
927                },
928                ResourceTypeSummary {
929                    name: "basicLetter".into(),
930                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
931                    label: None,
932                },
933                ResourceTypeSummary {
934                    name: "letter".into(),
935                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
936                    label: Some("Letter".into()),
937                },
938            ],
939        }
940    }
941
942    #[test]
943    fn data_model_describe_lines_output() {
944        let out = SharedBuf::new();
945        let err = SharedBuf::new();
946        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
947        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
948        renderer
949            .data_model_describe(&make_beol_dm_detail(), &meta)
950            .unwrap();
951
952        let stdout = out.string();
953        // Single row: name TAB iri
954        assert!(
955            stdout.contains("beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\n"),
956            "expected name TAB iri row; got:\n{stdout}"
957        );
958        // No header
959        assert!(!stdout.starts_with("name"));
960        // resource-types must NOT appear in lines output
961        assert!(
962            !stdout.contains("Archive"),
963            "resource-types must be omitted"
964        );
965    }
966
967    #[test]
968    fn data_model_describe_lines_stderr_disclosure() {
969        let out = SharedBuf::new();
970        let err = SharedBuf::new();
971        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
972        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
973        renderer
974            .data_model_describe(&make_beol_dm_detail(), &meta)
975            .unwrap();
976
977        let err_str = err.string();
978        assert_eq!(
979            err_str.trim(),
980            "[anonymous on https://api.test.dasch.swiss]"
981        );
982        // Disclosure must not appear on stdout
983        assert!(!out.string().contains("[anonymous"));
984    }
985
986    // ── resource_types lines tests ────────────────────────────────────────────
987
988    fn make_resource_type_fixture() -> Vec<ResourceType> {
989        vec![
990            ResourceType {
991                name: "Archive".into(),
992                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
993                label: Some("Archive".into()),
994                is_builtin: false,
995            },
996            ResourceType {
997                name: "letter".into(),
998                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
999                label: Some("Letter".into()),
1000                is_builtin: false,
1001            },
1002        ]
1003    }
1004
1005    #[test]
1006    fn resource_types_lines_output() {
1007        let out = SharedBuf::new();
1008        let err = SharedBuf::new();
1009        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1010        let view = ResourceTypeListView {
1011            items: make_resource_type_fixture(),
1012            total: 2,
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        let stdout = out.string();
1020        // No header
1021        assert!(!stdout.starts_with("name"));
1022        // name TAB iri rows
1023        assert!(stdout.contains("Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\n"));
1024        assert!(stdout.contains("letter\thttp://api.dasch.swiss/ontology/0801/beol/v2#letter\n"));
1025        // label/is_builtin NOT in lines output
1026        assert!(!stdout.contains("Letter"));
1027        assert!(!stdout.contains("true"));
1028        assert!(!stdout.contains("false"));
1029    }
1030
1031    #[test]
1032    fn resource_types_lines_with_builtins() {
1033        let out = SharedBuf::new();
1034        let err = SharedBuf::new();
1035        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1036        let mut items = make_resource_type_fixture();
1037        items.push(ResourceType {
1038            name: "Region".into(),
1039            iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1040            label: Some("Region".into()),
1041            is_builtin: true,
1042        });
1043        let view = ResourceTypeListView {
1044            items,
1045            total: 3,
1046            filter: None,
1047            data_model: "beol".into(),
1048        };
1049        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1050        renderer.resource_types(&view, &meta).unwrap();
1051
1052        let stdout = out.string();
1053        // built-in appears as a plain name TAB iri row
1054        assert!(stdout.contains("Region\thttp://api.knora.org/ontology/knora-api/v2#Region\n"));
1055        // no is_builtin column
1056        assert!(!stdout.contains("true"));
1057    }
1058
1059    #[test]
1060    fn resource_types_lines_stderr_disclosure() {
1061        let out = SharedBuf::new();
1062        let err = SharedBuf::new();
1063        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1064        let view = ResourceTypeListView {
1065            items: make_resource_type_fixture(),
1066            total: 2,
1067            filter: None,
1068            data_model: "beol".into(),
1069        };
1070        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1071        renderer.resource_types(&view, &meta).unwrap();
1072
1073        let err_str = err.string();
1074        assert_eq!(
1075            err_str.trim(),
1076            "[anonymous on https://api.test.dasch.swiss]"
1077        );
1078        assert!(!out.string().contains("[anonymous"));
1079    }
1080
1081    #[test]
1082    fn resource_types_lines_empty() {
1083        let out = SharedBuf::new();
1084        let err = SharedBuf::new();
1085        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1086        let view = ResourceTypeListView {
1087            items: vec![],
1088            total: 0,
1089            filter: None,
1090            data_model: "beol".into(),
1091        };
1092        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1093        renderer.resource_types(&view, &meta).unwrap();
1094
1095        // No data rows; disclosure still emitted to stderr
1096        assert_eq!(out.string(), "");
1097        assert!(
1098            err.string()
1099                .contains("[anonymous on https://api.test.dasch.swiss]")
1100        );
1101    }
1102
1103    // ── auth_logout lines tests (D6 bypass + projected path) ─────────────────
1104
1105    #[test]
1106    fn auth_logout_lines_default_decorated_output() {
1107        // Default path: bespoke writeln! produces <server>\twas_cached=<bool>.
1108        let out = SharedBuf::new();
1109        let mut renderer = LinesRenderer::with_writer(out.clone());
1110        renderer
1111            .auth_logout(
1112                &AuthLogoutOutcome {
1113                    server: "https://api.example.com".to_string(),
1114                    was_cached: true,
1115                },
1116                &make_meta("anonymous", "https://api.example.com"),
1117            )
1118            .unwrap();
1119        assert_eq!(out.string(), "https://api.example.com\twas_cached=true\n");
1120    }
1121
1122    #[test]
1123    fn auth_logout_lines_projected_was_cached_plain_value() {
1124        // Projected path via engine: --columns was_cached → "true\n" (plain bool string,
1125        // no "was_cached=" prefix). Pins plan 020 D6's projected path contract.
1126        let out = SharedBuf::new();
1127        let mut renderer = LinesRenderer::with_writer(out.clone()).with_options(TableOptions {
1128            columns: Some(vec!["was_cached".to_string()]),
1129            header: HeaderMode::Off,
1130        });
1131        renderer
1132            .auth_logout(
1133                &AuthLogoutOutcome {
1134                    server: "https://api.example.com".to_string(),
1135                    was_cached: true,
1136                },
1137                &make_meta("anonymous", "https://api.example.com"),
1138            )
1139            .unwrap();
1140        assert_eq!(out.string(), "true\n");
1141    }
1142
1143    // ── resource_describe lines values-note tests ─────────────────────────────
1144
1145    use crate::model::{
1146        FieldValues, ResourceAccess, ResourceDetail, ResourceVisibility, ValueContent,
1147    };
1148
1149    fn make_resource_detail(values: Option<Vec<FieldValues>>) -> ResourceDetail {
1150        ResourceDetail {
1151            label: "Test Resource".into(),
1152            iri: "http://rdfh.ch/0803/abc123".into(),
1153            resource_type: "Page".into(),
1154            ark_url: None,
1155            creation_date: None,
1156            last_modified: None,
1157            attached_project: None,
1158            owner: None,
1159            visibility: Some(ResourceVisibility::Public),
1160            your_access: Some(ResourceAccess::View),
1161            values,
1162        }
1163    }
1164
1165    #[test]
1166    fn resource_describe_lines_no_values_no_note() {
1167        // When values is None, no stderr note is emitted; the metadata row renders
1168        // unchanged.
1169        let out = SharedBuf::new();
1170        let err = SharedBuf::new();
1171        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1172        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1173        renderer
1174            .resource_describe(&make_resource_detail(None), &meta)
1175            .unwrap();
1176
1177        let err_str = err.string();
1178        assert!(
1179            !err_str.contains("values not shown"),
1180            "no values note must be emitted when values is None; got stderr:\n{err_str}"
1181        );
1182        // Metadata row still present on stdout
1183        assert!(
1184            out.string().contains("Test Resource"),
1185            "metadata row missing; got:\n{}",
1186            out.string()
1187        );
1188    }
1189
1190    #[test]
1191    fn resource_describe_lines_some_values_no_note_renders_value_rows() {
1192        // When values is Some, no stderr note is emitted; stdout carries one row
1193        // per value in the compact default columns (field, field_label, value_type,
1194        // value) — no label/iri (opt-in only).
1195        let out = SharedBuf::new();
1196        let err = SharedBuf::new();
1197        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1198        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1199        renderer
1200            .resource_describe(
1201                &make_resource_detail(Some(vec![FieldValues {
1202                    name: "hasTitle".into(),
1203                    label: None,
1204                    values: vec![ValueContent::Text("Hello".into()).into()],
1205                }])),
1206                &meta,
1207            )
1208            .unwrap();
1209
1210        let err_str = err.string();
1211        assert!(
1212            !err_str.contains("values not shown"),
1213            "no values note must be emitted when values is Some; got stderr:\n{err_str}"
1214        );
1215        let stdout = out.string();
1216        assert!(
1217            stdout.contains("hasTitle\t\ttext\tHello\n"),
1218            "expected default-columns value row field\\tfield_label\\tvalue_type\\tvalue; got:\n{stdout}"
1219        );
1220        // Metadata (label/iri) is dropped in values mode by default.
1221        assert!(
1222            !stdout.contains("Test Resource"),
1223            "metadata row must not appear in values mode; got:\n{stdout}"
1224        );
1225    }
1226
1227    #[test]
1228    fn resource_describe_lines_columns_comment_renders_comment_column() {
1229        // comment is opt-in via --columns; selecting it must surface the
1230        // per-value comment text in the projected column position, and the
1231        // default-columns test above must stay unaffected (comment absent).
1232        use crate::model::Value;
1233
1234        let out = SharedBuf::new();
1235        let err = SharedBuf::new();
1236        let mut renderer =
1237            LinesRenderer::with_writers(out.clone(), err.clone()).with_options(TableOptions {
1238                columns: Some(vec![
1239                    "field".to_string(),
1240                    "value".to_string(),
1241                    "comment".to_string(),
1242                ]),
1243                header: HeaderMode::Off,
1244            });
1245        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1246        renderer
1247            .resource_describe(
1248                &make_resource_detail(Some(vec![FieldValues {
1249                    name: "hasTranscription".into(),
1250                    label: None,
1251                    values: vec![Value {
1252                        content: ValueContent::Text("teh book".into()),
1253                        comment: Some("reading uncertain".into()),
1254                    }],
1255                }])),
1256                &meta,
1257            )
1258            .unwrap();
1259
1260        let stdout = out.string();
1261        assert!(
1262            stdout.contains("hasTranscription\tteh book\treading uncertain\n"),
1263            "expected projected field/value/comment row; got:\n{stdout}"
1264        );
1265    }
1266
1267    #[test]
1268    fn resource_describe_lines_default_columns_omits_comment_even_when_present() {
1269        // comment is opt-in via --columns, NOT part of the default column set —
1270        // this must hold even when the underlying value DOES carry a comment
1271        // (distinct from `resource_describe_lines_some_values_no_note_renders_value_rows`,
1272        // whose fixture has no comment at all and so cannot demonstrate this).
1273        use crate::model::Value;
1274
1275        let out = SharedBuf::new();
1276        let err = SharedBuf::new();
1277        let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1278        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1279        renderer
1280            .resource_describe(
1281                &make_resource_detail(Some(vec![FieldValues {
1282                    name: "hasTranscription".into(),
1283                    label: None,
1284                    values: vec![Value {
1285                        content: ValueContent::Text("teh book".into()),
1286                        comment: Some("reading uncertain".into()),
1287                    }],
1288                }])),
1289                &meta,
1290            )
1291            .unwrap();
1292
1293        let stdout = out.string();
1294        assert!(
1295            stdout.contains("hasTranscription\t\ttext\tteh book\n"),
1296            "expected default-columns row field\\tfield_label\\tvalue_type\\tvalue \
1297             (no comment column); got:\n{stdout}"
1298        );
1299        assert!(
1300            !stdout.contains("reading uncertain"),
1301            "comment must NOT appear under default columns even though the value \
1302             has one — comment is opt-in via --columns, not in the default set; \
1303             got:\n{stdout}"
1304        );
1305    }
1306}