Skip to main content

dsp_cli/render/
prose.rs

1//! Prose renderer — rich human-readable output.
2//!
3//! This is the default renderer (per dsp-cli/ADR-0003). `diagnostic` and the three
4//! auth methods produce human-readable one-liners. Per-noun methods arrive
5//! with real data: `project_dump` in Phase 3, `projects` (list) in Phase 4;
6//! the remaining noun-groups (data-models, etc.) follow in Phase 5. Prose
7//! output is irreducibly per-noun — each method is bespoke.
8
9use std::io::{self, Write};
10
11use chrono::{DateTime, Utc};
12
13use crate::diagnostic::Diagnostic;
14use crate::model::{DataModelDetail, DataModelStructure, ProjectDetail, VocabularyDetail};
15use crate::render::auth::{AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome};
16use crate::render::dump::{DumpDeleteOutcome, DumpOutcome};
17use crate::render::table::render_prose_footer;
18use crate::render::value::render_value_content;
19use crate::render::vocabulary::{flatten_vocabulary_detail, join_labels_prose};
20use crate::render::{
21    DataModelListView, MetaContext, ProjectListView, Renderer, ResourceListPagination, ResourceListView,
22    ResourceTypeListView, VocabularyListView,
23};
24use crate::util::text::{html_to_text, strip_control_chars};
25
26/// Format the expiry clause for a token's prose line.
27///
28/// Returns one of:
29/// - `" Token expires <ts>."` — expiry is known and in the future.
30/// - `" Token expired <ts>."` — expiry is known and in the past.
31/// - `""` — no expiry information (caller controls how to treat the absent case).
32///
33/// Note: this helper returns an empty string for `None`; callers that need
34/// "expiry unknown" (e.g. the `AuthenticatedViaEnv` arm) must handle `None`
35/// themselves.
36fn format_expiry_clause(expires_at: Option<DateTime<Utc>>, expired: bool) -> String {
37    match expires_at {
38        Some(exp) if expired => {
39            format!(" Token expired {}.", exp.format("%Y-%m-%d %H:%M UTC"))
40        }
41        Some(exp) => {
42            format!(" Token expires {}.", exp.format("%Y-%m-%d %H:%M UTC"))
43        }
44        None => String::new(),
45    }
46}
47
48/// Renders output as rich human-readable prose.
49pub struct ProseRenderer {
50    out: Box<dyn Write>,
51}
52
53impl ProseRenderer {
54    /// Creates a renderer writing to stdout.
55    ///
56    /// Stdout is wrapped in `BrokenPipeWriter` so `dsp ... | head` exits 0
57    /// silently instead of surfacing a broken pipe as `Diagnostic::Internal`.
58    pub fn new() -> Self {
59        Self {
60            out: Box::new(crate::util::BrokenPipeWriter::new(io::stdout())),
61        }
62    }
63
64    /// Creates a renderer writing to an arbitrary `Write` sink (used in tests).
65    pub fn with_writer(w: impl Write + 'static) -> Self {
66        Self { out: Box::new(w) }
67    }
68}
69
70impl Default for ProseRenderer {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl Renderer for ProseRenderer {
77    fn diagnostic(&mut self, diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
78        eprintln!("Error: {diag}"); // errors go to stderr for non-JSON formats (dsp-cli/ADR-0012)
79        Ok(())
80    }
81
82    fn auth_login(&mut self, outcome: &AuthLoginOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
83        match &outcome.expires_at {
84            Some(exp) => writeln!(
85                self.out,
86                "Logged in to {} as {}. Token expires {}.",
87                outcome.server,
88                outcome.user,
89                exp.format("%Y-%m-%d %H:%M UTC"),
90            )?,
91            None => writeln!(self.out, "Logged in to {} as {}.", outcome.server, outcome.user,)?,
92        }
93        Ok(())
94    }
95
96    fn auth_status(&mut self, outcome: &AuthStatusOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
97        match outcome {
98            AuthStatusOutcome::LoggedIn { server, user, expires_at, expired } => {
99                let user_str = user.as_deref().map(|u| format!(" as {u}")).unwrap_or_default();
100                let expiry_str = format_expiry_clause(*expires_at, *expired);
101                writeln!(self.out, "Logged in to {server}{user_str}.{expiry_str}")?;
102            }
103            AuthStatusOutcome::AuthenticatedViaEnv { server, expires_at, expired } => {
104                let expiry_str = match expires_at {
105                    None => " Token expiry unknown.".to_string(),
106                    Some(_) => format_expiry_clause(*expires_at, *expired),
107                };
108                writeln!(self.out, "Authenticated to {server} via DSP_TOKEN.{expiry_str}")?;
109            }
110            AuthStatusOutcome::NotLoggedIn { server } => {
111                writeln!(self.out, "Not logged in to {server}.")?;
112            }
113        }
114        Ok(())
115    }
116
117    fn auth_logout(&mut self, outcome: &AuthLogoutOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
118        if outcome.was_cached {
119            writeln!(self.out, "Logged out of {}.", outcome.server)?;
120        } else {
121            writeln!(self.out, "Not logged in to {} (nothing to remove).", outcome.server)?;
122        }
123        Ok(())
124    }
125
126    fn auth_set_token(&mut self, outcome: &AuthSetTokenOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
127        let user_clause = outcome.user.as_deref().map(|u| format!(" as {u}")).unwrap_or_default();
128        let expiry_clause = match outcome.expires_at {
129            Some(exp) => format!(" Token expires {}.", exp.format("%Y-%m-%d %H:%M UTC")),
130            None => String::new(),
131        };
132        writeln!(
133            self.out,
134            "Cached token for {server}{user_clause}.{expiry_clause}",
135            server = outcome.server,
136        )?;
137        Ok(())
138    }
139
140    fn project_dump(&mut self, outcome: &DumpOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
141        if outcome.reused {
142            // Adopt path: announce the existing dump with its creation timestamp
143            // if known, then the local file line.
144            match outcome.created_at {
145                Some(ts) => writeln!(
146                    self.out,
147                    "Downloaded existing dump (created {}).",
148                    ts.format("%Y-%m-%d %H:%M UTC"),
149                )?,
150                None => writeln!(self.out, "Downloaded existing dump.")?,
151            }
152        }
153        writeln!(self.out, "Wrote {} ({} bytes).", outcome.path.display(), outcome.bytes,)?;
154        if outcome.cleaned_up {
155            writeln!(self.out, "Cleaned up the server-side dump.")?;
156        }
157        Ok(())
158    }
159
160    fn project_dump_deleted(&mut self, outcome: &DumpDeleteOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
161        if outcome.deleted {
162            writeln!(self.out, "Removed the project's dump.")?;
163        } else if let Some(ref note) = outcome.note {
164            writeln!(self.out, "{note}")?;
165        }
166        Ok(())
167    }
168
169    fn projects(&mut self, view: &ProjectListView, meta: &MetaContext) -> Result<(), Diagnostic> {
170        // Count line: with or without filter.
171        let n = view.items.len();
172        match &view.filter {
173            None => writeln!(self.out, "Projects on {} ({n}):", meta.server_label)?,
174            Some(f) => writeln!(
175                self.out,
176                "Projects on {} ({n} of {} matching \"{f}\"):",
177                meta.server_label, view.total
178            )?,
179        }
180
181        writeln!(self.out)?;
182
183        // Compute column widths for alignment.
184        let sc_w = view.items.iter().map(|p| p.shortcode.len()).max().unwrap_or(0);
185        let sn_w = view.items.iter().map(|p| p.shortname.len()).max().unwrap_or(0);
186        let ln_w = view
187            .items
188            .iter()
189            .map(|p| p.longname.as_deref().unwrap_or("").len())
190            .max()
191            .unwrap_or(0);
192
193        for item in &view.items {
194            let longname = item.longname.as_deref().unwrap_or("");
195            // iri is intentionally omitted from prose (dsp-cli/ADR-0003 / plan Step 3c).
196            // The data-models hint is right-appended to the row (same line as
197            // status), per the locked PRD output format.
198            write!(
199                self.out,
200                "  {:<sc_w$}  {:<sn_w$}  {:<ln_w$}  {}",
201                item.shortcode,
202                item.shortname,
203                longname,
204                item.status.as_str(),
205            )?;
206            if item.data_models > 0 {
207                let label = if item.data_models == 1 {
208                    "data-model"
209                } else {
210                    "data-models"
211                };
212                write!(self.out, "   \u{b7} {} {label}", item.data_models)?;
213            }
214            writeln!(self.out)?;
215        }
216
217        render_prose_footer(&mut *self.out, meta)?;
218        Ok(())
219    }
220
221    fn project_describe(&mut self, project: &ProjectDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
222        // Header: "Project: <shortname> (<shortcode>)" — always this shape.
223        // Short identifiers form the title; the longname becomes a labeled field below.
224        writeln!(self.out, "Project: {} ({})", project.shortname, project.shortcode)?;
225
226        // Label/value block — 2-space indent, values aligned to a common column.
227        // "  Keywords:   " is the longest label (13 chars incl. colon + spaces).
228
229        // Name (longname) — omit when None.
230        if let Some(ln) = &project.longname {
231            writeln!(self.out, "  Name:       {ln}")?;
232        }
233
234        writeln!(self.out, "  IRI:        {}", project.iri)?;
235        writeln!(self.out, "  Status:     {}", project.status.as_str())?;
236
237        // Keywords — omit line when empty.
238        if !project.keywords.is_empty() {
239            writeln!(self.out, "  Keywords:   {}", project.keywords.join(", "))?;
240        }
241
242        // Data-models — always present; no trailing colon/names when n==0.
243        let n = project.data_models.len();
244        if n == 0 {
245            writeln!(self.out, "  Data-models (0)")?;
246        } else {
247            let names: Vec<&str> = project.data_models.iter().map(|dm| dm.name.as_str()).collect();
248            writeln!(self.out, "  Data-models ({n}): {}", names.join(", "))?;
249        }
250
251        // Description — each entry is run through html_to_text before rendering
252        // (prose-only; JSON keeps the raw value per dsp-cli/ADR-0003). Entries whose
253        // value reduces to empty text (e.g. only tags) are dropped, and the whole
254        // block — including the `Description:` label — is omitted when nothing
255        // visible remains, so the label never dangles with no content under it.
256        let descriptions: Vec<(Option<&str>, String)> = project
257            .description
258            .iter()
259            .map(|e| (e.language.as_deref(), html_to_text(&e.value)))
260            .filter(|(_, plain)| !plain.is_empty())
261            .collect();
262        if !descriptions.is_empty() {
263            writeln!(self.out, "  Description:")?;
264            for (language, plain) in &descriptions {
265                // Render each line of the plain-text value with a 4-space base indent.
266                // Language tag `[<lang>]` prefixes the first line; continuation lines
267                // are indented to align with the first-line text.
268                let prefix = match language {
269                    Some(lang) => format!("    [{lang}] "),
270                    None => "    ".to_string(),
271                };
272                let continuation_indent = " ".repeat(prefix.len());
273                let mut first_line = true;
274                for line in plain.lines() {
275                    if first_line {
276                        writeln!(self.out, "{prefix}{line}")?;
277                        first_line = false;
278                    } else if line.is_empty() {
279                        // Blank separator line between paragraphs: emit it truly
280                        // empty, not as a whitespace-only (indented) line.
281                        writeln!(self.out)?;
282                    } else {
283                        writeln!(self.out, "{continuation_indent}{line}")?;
284                    }
285                }
286            }
287        }
288
289        render_prose_footer(&mut *self.out, meta)?;
290        Ok(())
291    }
292
293    fn data_model_describe(&mut self, detail: &DataModelDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
294        // Header: "Data-model: {name}"
295        writeln!(self.out, "Data-model: {}", detail.name)?;
296
297        // Label/value block — 2-space indent, values aligned to a common column.
298        // Longest label key is "Last-modified:" (14 chars incl. colon + 2 trailing spaces = 16
299        // wide). Label: omit when None; IRI: always; Last-modified: omit when None.
300        if let Some(ref lbl) = detail.label {
301            writeln!(self.out, "  Label:          {lbl}")?;
302        }
303        writeln!(self.out, "  IRI:            {}", detail.iri)?;
304        if let Some(ref lm) = detail.last_modified {
305            let date = lm.split_once('T').map(|(d, _)| d).unwrap_or(lm.as_str());
306            writeln!(self.out, "  Last-modified:  {date}")?;
307        }
308
309        // Resource-types summary block.
310        let n = detail.resource_types.len();
311        if n == 0 {
312            writeln!(self.out, "  Resource-types (0)")?;
313        } else {
314            writeln!(self.out)?;
315            writeln!(self.out, "  Resource-types ({n}):")?;
316            // Compute max name width for alignment.
317            let name_w = detail.resource_types.iter().map(|rt| rt.name.len()).max().unwrap_or(0);
318            for rt in &detail.resource_types {
319                let label = rt.label.as_deref().unwrap_or("");
320                writeln!(self.out, "    {:<name_w$}  {label}", rt.name)?;
321            }
322        }
323
324        render_prose_footer(&mut *self.out, meta)?;
325        Ok(())
326    }
327
328    fn data_models(&mut self, view: &DataModelListView, meta: &MetaContext) -> Result<(), Diagnostic> {
329        let n = view.items.len();
330        let has_builtins = view.items.iter().any(|d| d.is_builtin);
331        let builtin_suffix = if has_builtins { ", incl. built-ins" } else { "" };
332
333        // Header line: with or without filter.
334        match &view.filter {
335            None => writeln!(self.out, "data-models on {} ({n}){builtin_suffix}:", meta.server_label,)?,
336            Some(f) => writeln!(
337                self.out,
338                "data-models on {} ({n} of {} matching \"{f}\"){builtin_suffix}:",
339                meta.server_label, view.total,
340            )?,
341        }
342
343        writeln!(self.out)?;
344
345        // Compute column widths for alignment.
346        let name_w = view.items.iter().map(|d| d.name.len()).max().unwrap_or(0);
347        let label_w = view
348            .items
349            .iter()
350            .map(|d| {
351                if let Some(ref lbl) = d.label {
352                    lbl.len()
353                } else if d.is_builtin {
354                    "(built-in)".len()
355                } else {
356                    0
357                }
358            })
359            .max()
360            .unwrap_or(0);
361
362        for item in &view.items {
363            // label_or_marker: label, else "(built-in)" for builtins, else "".
364            let label_or_marker: &str = if let Some(ref lbl) = item.label {
365                lbl.as_str()
366            } else if item.is_builtin {
367                "(built-in)"
368            } else {
369                ""
370            };
371
372            // date: YYYY-MM-DD prefix via split_once('T'), empty when None.
373            let date_str: String = match &item.last_modified {
374                Some(s) => s.split_once('T').map(|(d, _)| d.to_string()).unwrap_or_else(|| s.clone()),
375                None => String::new(),
376            };
377
378            writeln!(
379                self.out,
380                "  {:<name_w$}  {:<label_w$}  {}",
381                item.name, label_or_marker, date_str,
382            )?;
383        }
384
385        render_prose_footer(&mut *self.out, meta)?;
386        Ok(())
387    }
388
389    fn resource_types(&mut self, view: &ResourceTypeListView, meta: &MetaContext) -> Result<(), Diagnostic> {
390        let n = view.items.len();
391        let has_builtins = view.items.iter().any(|rt| rt.is_builtin);
392        let has_counts = view.items.iter().any(|rt| rt.count.is_some());
393        let builtin_suffix = if has_builtins { ", incl. built-ins" } else { "" };
394
395        // Header line: with or without filter.
396        match &view.filter {
397            None => writeln!(
398                self.out,
399                "resource-types in {} on {} ({n}){builtin_suffix}:",
400                view.data_model, meta.server_label,
401            )?,
402            Some(f) => writeln!(
403                self.out,
404                "resource-types in {} on {} ({n} of {} matching \"{f}\"){builtin_suffix}:",
405                view.data_model, meta.server_label, view.total,
406            )?,
407        }
408
409        writeln!(self.out)?;
410
411        // Compute column widths for alignment.
412        let name_w = view.items.iter().map(|rt| rt.name.len()).max().unwrap_or(0);
413        let label_w = view
414            .items
415            .iter()
416            .map(|rt| rt.label.as_deref().map(|l| l.len()).unwrap_or(0))
417            .max()
418            .unwrap_or(0);
419
420        if has_counts {
421            // Right-aligned COUNT column, shown only when at least one item
422            // carries a count (plan 030). label is now padded in both branches
423            // so the count column aligns whether or not a row is built-in.
424            let count_w = view
425                .items
426                .iter()
427                .map(|rt| rt.count.map(|c| c.to_string()).unwrap_or_default().len())
428                .max()
429                .unwrap_or(0);
430            for item in &view.items {
431                let label = item.label.as_deref().unwrap_or("");
432                let count_str = item.count.map(|c| c.to_string()).unwrap_or_default();
433                if item.is_builtin {
434                    writeln!(
435                        self.out,
436                        "  {:<name_w$}  {:<label_w$}  {:>count_w$}  (built-in)",
437                        item.name, label, count_str,
438                    )?;
439                } else {
440                    writeln!(
441                        self.out,
442                        "  {:<name_w$}  {:<label_w$}  {:>count_w$}",
443                        item.name, label, count_str,
444                    )?;
445                }
446            }
447        } else {
448            for item in &view.items {
449                let label = item.label.as_deref().unwrap_or("");
450                // Built-in rows get an explicit trailing marker; project rows get nothing.
451                if item.is_builtin {
452                    writeln!(self.out, "  {:<name_w$}  {:<label_w$}  (built-in)", item.name, label,)?;
453                } else {
454                    writeln!(self.out, "  {:<name_w$}  {label}", item.name)?;
455                }
456            }
457        }
458
459        render_prose_footer(&mut *self.out, meta)?;
460        Ok(())
461    }
462
463    fn resource_type_describe(
464        &mut self,
465        detail: &crate::model::ResourceTypeDetail,
466        meta: &MetaContext,
467    ) -> Result<(), Diagnostic> {
468        use crate::model::ValueType;
469
470        // Header: "Resource-type: <name>"
471        writeln!(self.out, "Resource-type: {}", detail.name)?;
472
473        // Optional label/extends/representation header block — 2-space indent,
474        // values aligned to a common column (longest label key is "Representation:"
475        // at 15 chars + 2-space prefix + 2-space gap = col 19).
476        if let Some(ref lbl) = detail.label {
477            writeln!(self.out, "  Label:          {lbl}")?;
478        }
479        if !detail.super_types.is_empty() {
480            writeln!(self.out, "  Extends:        {}", detail.super_types.join(", "))?;
481        }
482        if let Some(ref repr) = detail.representation {
483            writeln!(self.out, "  Representation: {repr}")?;
484        }
485        writeln!(self.out, "  IRI:            {}", detail.iri)?;
486        writeln!(self.out, "  Data-model:     {}", detail.data_model)?;
487        if let Some(count) = detail.count {
488            writeln!(self.out, "  Instances:      {count}")?;
489        }
490
491        // Fields block.
492        let n = detail.fields.len();
493        if n == 0 {
494            writeln!(self.out, "\n  Fields (0)")?;
495        } else {
496            writeln!(self.out, "\n  Fields ({n}):")?;
497
498            // Build rendered value-type strings (link → Target or plain value-type).
499            let vtype_strs: Vec<String> = detail
500                .fields
501                .iter()
502                .map(|f| match &f.value_type {
503                    ValueType::Link => {
504                        if let Some(ref tgt) = f.link_target {
505                            format!("link \u{2192} {tgt}")
506                        } else {
507                            "link".to_string()
508                        }
509                    }
510                    vt => vt.to_string(),
511                })
512                .collect();
513
514            // Compute column widths.
515            let name_w = detail.fields.iter().map(|f| f.name.len()).max().unwrap_or(0);
516            let vtype_w = vtype_strs.iter().map(|s| s.len()).max().unwrap_or(0);
517            let card_w = detail.fields.iter().map(|f| f.cardinality.to_string().len()).max().unwrap_or(0);
518
519            for (field, vtype_str) in detail.fields.iter().zip(vtype_strs.iter()) {
520                let label = field.label.as_deref().unwrap_or("");
521                let card_str = field.cardinality.to_string();
522
523                // Source tag: append when field's data_model is Some and differs from
524                // the resource-type's own data_model.
525                let source_tag = if let Some(ref dm) = field.data_model {
526                    if dm != &detail.data_model {
527                        format!("  [from {dm}]")
528                    } else {
529                        String::new()
530                    }
531                } else {
532                    String::new()
533                };
534
535                // Built-in marker: append after label when is_builtin.
536                let builtin_marker = if field.is_builtin { "  (built-in)" } else { "" };
537
538                writeln!(
539                    self.out,
540                    "    {:<name_w$}  {:<vtype_w$}  {:<card_w$}  {label}{builtin_marker}{source_tag}",
541                    field.name, vtype_str, card_str,
542                )?;
543            }
544        }
545
546        render_prose_footer(&mut *self.out, meta)?;
547        Ok(())
548    }
549
550    fn data_model_structure(&mut self, structure: &DataModelStructure, meta: &MetaContext) -> Result<(), Diagnostic> {
551        use crate::model::RelationKind;
552
553        // Header: "Structure: <dm>  (<n> relations)"
554        let n = structure.relations.len();
555        let plural = if n == 1 { "relation" } else { "relations" };
556        writeln!(self.out, "Structure: {}  ({n} {plural})", structure.data_model)?;
557
558        if n > 0 {
559            writeln!(self.out)?;
560
561            // Compute column widths.
562            // source_w: max source name length.
563            let source_w = structure.relations.iter().map(|r| r.source.len()).max().unwrap_or(0);
564
565            // target_w: max of (target + optional " [to <dm>]") length.
566            let target_w = structure
567                .relations
568                .iter()
569                .map(|r| {
570                    let tag_len = match &r.target_data_model {
571                        Some(dm) if dm != &structure.data_model => " [to ".len() + dm.len() + "]".len(),
572                        _ => 0,
573                    };
574                    r.target.len() + tag_len
575                })
576                .max()
577                .unwrap_or(0);
578
579            // field_w: max field name length (empty string for inherits).
580            let field_w = structure
581                .relations
582                .iter()
583                .map(|r| r.field.as_deref().unwrap_or("").len())
584                .max()
585                .unwrap_or(0);
586
587            for rel in &structure.relations {
588                // Build the target column: target + optional "[to <dm>]" tag.
589                let target_col = match &rel.target_data_model {
590                    Some(dm) if dm != &structure.data_model => {
591                        format!("{} [to {}]", rel.target, dm)
592                    }
593                    _ => rel.target.clone(),
594                };
595
596                // field column: field name for link, empty for inherits.
597                let field_col = rel.field.as_deref().unwrap_or("");
598
599                // kind marker.
600                let kind_marker = match rel.kind {
601                    RelationKind::Link => "[link]",
602                    RelationKind::Inherits => "[inherits]",
603                };
604
605                writeln!(
606                    self.out,
607                    "  {:<source_w$}   \u{2192} {:<target_w$}   {:<field_w$}   {kind_marker}",
608                    rel.source, target_col, field_col,
609                )?;
610            }
611        }
612
613        render_prose_footer(&mut *self.out, meta)?;
614        Ok(())
615    }
616
617    fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
618        let n = view.items.len();
619
620        // Header line: with or without filter.
621        match &view.filter {
622            None => writeln!(
623                self.out,
624                "resources of type {} on {} ({n}):",
625                view.resource_type, meta.server_label,
626            )?,
627            Some(f) => writeln!(
628                self.out,
629                "resources of type {} on {} ({n} of {} matching \"{f}\"):",
630                view.resource_type, meta.server_label, view.total,
631            )?,
632        }
633
634        if n > 0 {
635            writeln!(self.out)?;
636
637            // Sanitise labels before computing the alignment width, so padding
638            // stays correct when a label contains control characters (stripping
639            // shortens the byte length the `{:<label_w$}` pad relies on).
640            let labels: Vec<String> = view.items.iter().map(|r| strip_control_chars(&r.label)).collect();
641            let label_w = labels.iter().map(|l| l.len()).max().unwrap_or(0);
642
643            for (item, label) in view.items.iter().zip(&labels) {
644                writeln!(self.out, "  {:<label_w$}  {}", label, item.iri,)?;
645            }
646        }
647
648        // "more results available" hint — only in SinglePage mode when may_have_more.
649        if let ResourceListPagination::SinglePage { may_have_more: true, .. } = view.pagination {
650            writeln!(self.out)?;
651            writeln!(self.out, "  more results available (use --all to fetch all pages)")?;
652        }
653
654        render_prose_footer(&mut *self.out, meta)?;
655        Ok(())
656    }
657
658    fn resource_describe(
659        &mut self,
660        detail: &crate::model::ResourceDetail,
661        meta: &MetaContext,
662    ) -> Result<(), Diagnostic> {
663        // Header: "Resource: <label>"
664        writeln!(self.out, "Resource: {}", strip_control_chars(&detail.label))?;
665
666        // Label/value block — 2-space indent, values aligned to a common column.
667        // Longest label key is "Your access:" (12 chars) + 2-space prefix + gap.
668        // Column alignment: pad to 14 chars for the label (incl. colon).
669        // "Your access:  " = 12 + 2 = 14 wide.
670        writeln!(self.out, "  Type:         {}", detail.resource_type)?;
671        writeln!(self.out, "  IRI:          {}", detail.iri)?;
672        if let Some(ref ark) = detail.ark_url {
673            writeln!(self.out, "  ARK:          {ark}")?;
674        }
675        if let Some(ref created) = detail.creation_date {
676            writeln!(self.out, "  Created:      {created}")?;
677        }
678        if let Some(ref modified) = detail.last_modified {
679            writeln!(self.out, "  Modified:     {modified}")?;
680        }
681        if let Some(ref project) = detail.attached_project {
682            writeln!(self.out, "  Project:      {project}")?;
683        }
684        if let Some(ref owner) = detail.owner {
685            writeln!(self.out, "  Owner:        {owner}")?;
686        }
687        if let Some(ref vis) = detail.visibility {
688            writeln!(self.out, "  Visibility:   {}", vis.as_str())?;
689        }
690        if let Some(ref access) = detail.your_access {
691            writeln!(self.out, "  Your access:  {}", access.as_str())?;
692        }
693
694        // Values section — only when `--values` was set (detail.values is Some).
695        if let Some(ref fields) = detail.values {
696            writeln!(self.out)?;
697            if fields.is_empty() {
698                writeln!(self.out, "Values: (none)")?;
699            } else {
700                writeln!(self.out, "Values:")?;
701                for fv in fields {
702                    // Field header: "<label> (<name>)" when label is Some, else "<name>".
703                    let header = match &fv.label {
704                        Some(lbl) => {
705                            format!("{} ({})", strip_control_chars(lbl), strip_control_chars(&fv.name))
706                        }
707                        None => strip_control_chars(&fv.name),
708                    };
709                    writeln!(self.out, "  {header}")?;
710
711                    for value in &fv.values {
712                        let rendered = render_value_content(&value.content);
713                        writeln!(self.out, "    {}", strip_control_chars(&rendered))?;
714                        if let Some(comment) = &value.comment {
715                            writeln!(self.out, "      comment: {}", strip_control_chars(comment))?;
716                        }
717                    }
718                }
719            }
720        }
721
722        render_prose_footer(&mut *self.out, meta)?;
723        Ok(())
724    }
725
726    fn vocabularies(&mut self, view: &VocabularyListView, meta: &MetaContext) -> Result<(), Diagnostic> {
727        // Header line: with or without filter (mirrors `projects`/`resource_types`).
728        let n = view.items.len();
729        match &view.filter {
730            None => writeln!(self.out, "Vocabularies on {} ({n}):", meta.server_label)?,
731            Some(f) => writeln!(
732                self.out,
733                "Vocabularies on {} ({n} of {} matching \"{f}\"):",
734                meta.server_label, view.total
735            )?,
736        }
737
738        writeln!(self.out)?;
739
740        for item in &view.items {
741            let name = strip_control_chars(item.header.name.as_deref().unwrap_or(""));
742            let labels = strip_control_chars(&join_labels_prose(&item.header.labels));
743            write!(self.out, "  {name}  {labels}")?;
744            // `--count` suffix: only when BOTH counted AND this item's per-tree
745            // fetch actually succeeded. A failed fetch (`None`) renders no
746            // suffix at all — the top-level `meta.count_cost` disclosure
747            // already covers the caveat; a per-row placeholder would be a
748            // second, redundant disclosure mechanism.
749            if view.counted
750                && let (Some(nodes), Some(depth)) = (item.node_count, item.depth)
751            {
752                let node_word = if nodes == 1 { "node" } else { "nodes" };
753                let level_word = if depth == 1 { "level" } else { "levels" };
754                write!(self.out, "  \u{b7} {nodes} {node_word} \u{b7} {depth} {level_word}")?;
755            }
756            writeln!(self.out)?;
757        }
758
759        render_prose_footer(&mut *self.out, meta)?;
760        Ok(())
761    }
762
763    fn vocabulary_describe(&mut self, detail: &VocabularyDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
764        let root = &detail.tree.root;
765        let root_name = strip_control_chars(root.name.as_deref().unwrap_or(""));
766        let root_labels = strip_control_chars(&join_labels_prose(&root.labels));
767        writeln!(self.out, "Vocabulary: {root_name} \u{2014} {root_labels}")?;
768        writeln!(self.out, "Root: {}", root.iri)?;
769
770        // Reuse the SAME flattened+filtered row set for both the header
771        // line's numbers (subtree-of / requested-node) and the body below —
772        // avoids a second flattening pass (plan 034 Step 3).
773        let rows = flatten_vocabulary_detail(detail);
774
775        let node_word = if detail.node_count == 1 { "node" } else { "nodes" };
776        let level_word = if detail.depth == 1 { "level" } else { "levels" };
777        write!(
778            self.out,
779            "{} {node_word} \u{b7} {} {level_word}",
780            detail.node_count, detail.depth
781        )?;
782        // The `--subtree` header note SUBSUMES the "you asked about" note —
783        // never show both (the subtree header already names the node).
784        if let Some(target_iri) = &detail.subtree_of
785            && let Some(top) = rows.iter().find(|n| &n.header.iri == target_iri)
786        {
787            write!(self.out, " \u{b7} subtree of {}", top.number)?;
788        } else if let Some(requested) = &detail.tree.requested_node
789            && let Some(row) = rows.iter().find(|n| &n.header.iri == requested)
790        {
791            write!(self.out, " \u{2190} you asked about {}", row.number)?;
792        }
793        writeln!(self.out)?;
794        writeln!(self.out)?;
795
796        // Prose indents by depth WITHIN THE RENDERED SET, not by absolute
797        // depth: under `--subtree` the branch starts at the left margin even
798        // though `number` still reads e.g. `1.1.2.1`.
799        let base_depth = rows.first().map(|n| n.depth).unwrap_or(1);
800        for node in &rows {
801            let indent = "  ".repeat(node.depth.saturating_sub(base_depth) + 1);
802            let labels = strip_control_chars(&join_labels_prose(&node.header.labels));
803            let marker = if detail.tree.requested_node.as_deref() == Some(node.header.iri.as_str()) {
804                "   \u{2190}"
805            } else {
806                ""
807            };
808            writeln!(self.out, "{indent}{}  {labels}{marker}", node.number)?;
809        }
810
811        render_prose_footer(&mut *self.out, meta)?;
812        Ok(())
813    }
814}
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819    use crate::model::{
820        Cardinality, DataModel, DataModelDetail, DataModelSummary, Field, Project, ProjectDescription, ProjectDetail,
821        ProjectStatus, Representation, ResourceType, ResourceTypeDetail, ResourceTypeSummary, ValueType,
822    };
823    use crate::render::test_support::{SharedBuf, make_meta};
824    use crate::render::{DataModelListView, ResourceTypeListView};
825
826    fn make_fixture() -> Vec<Project> {
827        vec![
828            Project {
829                iri: "http://rdfh.ch/projects/0001".into(),
830                shortcode: "0001".into(),
831                shortname: "anything".into(),
832                longname: Some("Anything Project".into()),
833                status: ProjectStatus::Active,
834                data_models: 2,
835            },
836            Project {
837                iri: "http://rdfh.ch/projects/0002".into(),
838                shortcode: "0002".into(),
839                shortname: "images".into(),
840                longname: None,
841                status: ProjectStatus::Inactive,
842                data_models: 0,
843            },
844            Project {
845                iri: "http://rdfh.ch/projects/0803".into(),
846                shortcode: "0803".into(),
847                shortname: "daschland".into(),
848                longname: Some("=Formula Project".into()),
849                status: ProjectStatus::Active,
850                data_models: 1,
851            },
852        ]
853    }
854
855    #[test]
856    fn projects_prose_no_filter() {
857        let out = SharedBuf::new();
858        let mut renderer = ProseRenderer::with_writer(out.clone());
859        let view = ProjectListView { items: make_fixture(), total: 3, filter: None };
860        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
861        renderer.projects(&view, &meta).unwrap();
862
863        let s = out.string();
864        assert!(s.contains("Projects on https://api.test.dasch.swiss (3):"));
865        // Footer on stdout (not stderr)
866        assert!(s.contains("[anonymous on https://api.test.dasch.swiss]"));
867        // IRI is omitted
868        assert!(!s.contains("rdfh.ch"));
869        // data_models line for active/2 project
870        assert!(s.contains("2 data-models"));
871        // singular for 1 data-model
872        assert!(s.contains("1 data-model"));
873        // no data-models line for 0
874        let data_model_lines = s.lines().filter(|l| l.contains("data-model")).count();
875        assert_eq!(data_model_lines, 2);
876        // None longname → empty (no "None" printed)
877        assert!(!s.contains("None"));
878    }
879
880    #[test]
881    fn projects_prose_with_filter() {
882        let out = SharedBuf::new();
883        let mut renderer = ProseRenderer::with_writer(out.clone());
884        let items = vec![make_fixture().remove(0)]; // just the "anything" project
885        let view = ProjectListView { items, total: 3, filter: Some("any".to_string()) };
886        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
887        renderer.projects(&view, &meta).unwrap();
888
889        let s = out.string();
890        assert!(s.contains("(1 of 3 matching \"any\")"));
891    }
892
893    #[test]
894    fn projects_prose_footer_on_stdout() {
895        // Prose writes the disclosure footer to stdout (not stderr).
896        let out = SharedBuf::new();
897        let mut renderer = ProseRenderer::with_writer(out.clone());
898        let view = ProjectListView { items: vec![], total: 0, filter: None };
899        let meta = make_meta("authenticated as alice", "https://api.dasch.swiss");
900        renderer.projects(&view, &meta).unwrap();
901
902        let s = out.string();
903        assert!(s.contains("[authenticated as alice on https://api.dasch.swiss]"));
904    }
905
906    fn make_beol_detail() -> ProjectDetail {
907        ProjectDetail {
908            iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
909            shortcode: "0801".into(),
910            shortname: "beol".into(),
911            longname: Some("Bernoulli-Euler Online".into()),
912            status: ProjectStatus::Active,
913            description: vec![ProjectDescription {
914                value: "<b>BEOL</b> — early modern mathematics.".into(),
915                language: Some("en".into()),
916            }],
917            keywords: vec!["Bernoulli".into(), "Euler".into(), "Mathematics".into()],
918            data_models: vec![
919                DataModelSummary {
920                    name: "beol".into(),
921                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
922                },
923                DataModelSummary {
924                    name: "biblio".into(),
925                    iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
926                },
927                DataModelSummary {
928                    name: "leibniz".into(),
929                    iri: "http://api.dasch.swiss/ontology/0801/leibniz/v2".into(),
930                },
931                DataModelSummary {
932                    name: "newton".into(),
933                    iri: "http://api.dasch.swiss/ontology/0801/newton/v2".into(),
934                },
935            ],
936        }
937    }
938
939    #[test]
940    fn project_describe_prose_full() {
941        let out = SharedBuf::new();
942        let mut renderer = ProseRenderer::with_writer(out.clone());
943        let meta = make_meta("anonymous", "api.dasch.swiss");
944        renderer.project_describe(&make_beol_detail(), &meta).unwrap();
945
946        let s = out.string();
947        // Header: shortname (shortcode) — new layout B
948        assert!(s.contains("Project: beol (0801)"));
949        // Longname is now a labeled field, not in the title
950        assert!(s.contains("Name:       Bernoulli-Euler Online"));
951        // Other label/value lines
952        assert!(s.contains("IRI:        http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF"));
953        assert!(s.contains("Status:     active"));
954        assert!(s.contains("Keywords:   Bernoulli, Euler, Mathematics"));
955        // Data-models with count + names
956        assert!(s.contains("Data-models (4): beol, biblio, leibniz, newton"));
957        // Description: plain text — no raw HTML tags, language prefix present
958        assert!(s.contains("[en] BEOL"));
959        assert!(!s.contains("<b>"), "description must not contain raw <b> tags");
960        assert!(!s.contains("</b>"), "description must not contain raw </b> tags");
961        // dsp-cli/ADR-0007 footer
962        assert!(s.contains("[anonymous on api.dasch.swiss]"));
963    }
964
965    #[test]
966    fn project_describe_prose_no_longname() {
967        let out = SharedBuf::new();
968        let mut renderer = ProseRenderer::with_writer(out.clone());
969        let mut detail = make_beol_detail();
970        detail.longname = None;
971        let meta = make_meta("anonymous", "api.dasch.swiss");
972        renderer.project_describe(&detail, &meta).unwrap();
973
974        let s = out.string();
975        // Header is shortname (shortcode); no Name: line
976        assert!(s.contains("Project: beol (0801)"));
977        assert!(!s.contains("Name:"), "Name: line must be absent when longname is None");
978    }
979
980    #[test]
981    fn project_describe_prose_empty_keywords_and_description() {
982        let out = SharedBuf::new();
983        let mut renderer = ProseRenderer::with_writer(out.clone());
984        let detail = ProjectDetail {
985            iri: "http://rdfh.ch/projects/0000".into(),
986            shortcode: "0000".into(),
987            shortname: "minimal".into(),
988            longname: None,
989            status: ProjectStatus::Inactive,
990            description: vec![],
991            keywords: vec![],
992            data_models: vec![],
993        };
994        let meta = make_meta("anonymous", "api.dasch.swiss");
995        renderer.project_describe(&detail, &meta).unwrap();
996
997        let s = out.string();
998        // Keywords line omitted
999        assert!(!s.contains("Keywords"));
1000        // Description block omitted
1001        assert!(!s.contains("Description"));
1002        // Data-models (0) with no names
1003        assert!(s.contains("Data-models (0)"));
1004        assert!(!s.contains("Data-models (0):"));
1005    }
1006
1007    #[test]
1008    fn project_describe_prose_description_no_language() {
1009        let out = SharedBuf::new();
1010        let mut renderer = ProseRenderer::with_writer(out.clone());
1011        let detail = ProjectDetail {
1012            iri: "http://rdfh.ch/projects/0001".into(),
1013            shortcode: "0001".into(),
1014            shortname: "test".into(),
1015            longname: None,
1016            status: ProjectStatus::Active,
1017            description: vec![ProjectDescription {
1018                value: "Plain description without language tag.".into(),
1019                language: None,
1020            }],
1021            keywords: vec![],
1022            data_models: vec![],
1023        };
1024        let meta = make_meta("anonymous", "api.dasch.swiss");
1025        renderer.project_describe(&detail, &meta).unwrap();
1026
1027        let s = out.string();
1028        // No language prefix on description line — value rendered with 4-space indent.
1029        assert!(s.contains("    Plain description without language tag."));
1030        // The description line itself must not have a language tag prefix like "[en]".
1031        let desc_line = s.lines().find(|l| l.contains("Plain description")).unwrap();
1032        assert!(!desc_line.contains('['));
1033    }
1034
1035    /// Two description entries: one with a language tag, one without.
1036    /// Plan: "Multiple description entries: render each, prefixing with `[<lang>]`
1037    /// only when `language` is set."
1038    #[test]
1039    fn project_describe_prose_two_descriptions_language_and_none() {
1040        let out = SharedBuf::new();
1041        let mut renderer = ProseRenderer::with_writer(out.clone());
1042        let detail = ProjectDetail {
1043            iri: "http://rdfh.ch/projects/0002".into(),
1044            shortcode: "0002".into(),
1045            shortname: "multilang".into(),
1046            longname: None,
1047            status: ProjectStatus::Active,
1048            description: vec![
1049                ProjectDescription {
1050                    value: "English description of this project.".into(),
1051                    language: Some("en".into()),
1052                },
1053                ProjectDescription {
1054                    value: "Description without a language tag.".into(),
1055                    language: None,
1056                },
1057            ],
1058            keywords: vec![],
1059            data_models: vec![],
1060        };
1061        let meta = make_meta("anonymous", "api.dasch.swiss");
1062        renderer.project_describe(&detail, &meta).unwrap();
1063
1064        let s = out.string();
1065
1066        // Both entries must appear in the rendered output.
1067        assert!(
1068            s.contains("English description of this project."),
1069            "first description value must appear"
1070        );
1071        assert!(
1072            s.contains("Description without a language tag."),
1073            "second description value must appear"
1074        );
1075
1076        // The languaged entry's value line must be prefixed with "[en] ".
1077        let en_line = s
1078            .lines()
1079            .find(|l| l.contains("English description"))
1080            .expect("expected a line containing 'English description'");
1081        assert!(
1082            en_line.contains("[en] "),
1083            "languaged entry must carry '[en] ' prefix; got: {en_line:?}"
1084        );
1085
1086        // The no-language entry's value line must NOT be prefixed with any "[...]".
1087        let plain_line = s
1088            .lines()
1089            .find(|l| l.contains("Description without a language tag."))
1090            .expect("expected a line containing 'Description without a language tag.'");
1091        assert!(
1092            !plain_line.contains('['),
1093            "no-language entry must not have a '[...]' prefix; got: {plain_line:?}"
1094        );
1095    }
1096
1097    /// A multi-paragraph description: blank separator lines between paragraphs
1098    /// must be rendered TRULY empty, not as whitespace-only (indented) lines.
1099    #[test]
1100    fn project_describe_prose_blank_lines_are_not_indented() {
1101        let out = SharedBuf::new();
1102        let mut renderer = ProseRenderer::with_writer(out.clone());
1103        let detail = ProjectDetail {
1104            iri: "http://rdfh.ch/projects/0003".into(),
1105            shortcode: "0003".into(),
1106            shortname: "multipara".into(),
1107            longname: None,
1108            status: ProjectStatus::Active,
1109            description: vec![ProjectDescription {
1110                value: "First paragraph.\n\nSecond paragraph.".into(),
1111                language: Some("en".into()),
1112            }],
1113            keywords: vec![],
1114            data_models: vec![],
1115        };
1116        let meta = make_meta("anonymous", "api.dasch.swiss");
1117        renderer.project_describe(&detail, &meta).unwrap();
1118
1119        let s = out.string();
1120        // Both paragraphs render (the continuation line is indented to align).
1121        assert!(s.contains("[en] First paragraph."));
1122        assert!(s.contains("Second paragraph."));
1123        // No line may be whitespace-only (non-empty but all-whitespace): the blank
1124        // separator between paragraphs must be a truly empty line.
1125        let ws_only = s.lines().find(|l| !l.is_empty() && l.trim().is_empty());
1126        assert!(
1127            ws_only.is_none(),
1128            "blank separator lines must be truly empty, not whitespace-only; got:\n{s}"
1129        );
1130    }
1131
1132    /// A description entry whose value reduces to empty text after html_to_text
1133    /// (e.g. only tags) must NOT leave a dangling `Description:` label.
1134    #[test]
1135    fn project_describe_prose_empty_after_html_omits_description_label() {
1136        let out = SharedBuf::new();
1137        let mut renderer = ProseRenderer::with_writer(out.clone());
1138        let detail = ProjectDetail {
1139            iri: "http://rdfh.ch/projects/0004".into(),
1140            shortcode: "0004".into(),
1141            shortname: "tagsonly".into(),
1142            longname: None,
1143            status: ProjectStatus::Active,
1144            description: vec![ProjectDescription {
1145                value: "<br/>".into(), // → "" after html_to_text
1146                language: Some("en".into()),
1147            }],
1148            keywords: vec![],
1149            data_models: vec![],
1150        };
1151        let meta = make_meta("anonymous", "api.dasch.swiss");
1152        renderer.project_describe(&detail, &meta).unwrap();
1153
1154        let s = out.string();
1155        assert!(
1156            !s.contains("Description:"),
1157            "Description: label must be omitted when no entry has visible text; got:\n{s}"
1158        );
1159    }
1160
1161    fn make_data_model_fixture() -> Vec<DataModel> {
1162        vec![
1163            DataModel {
1164                name: "beol".into(),
1165                iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1166                label: Some("The BEOL data-model".into()),
1167                last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1168                is_builtin: false,
1169            },
1170            DataModel {
1171                name: "biblio".into(),
1172                iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1173                label: None,
1174                last_modified: Some("2024-01-10T08:00:00.000000Z".into()),
1175                is_builtin: false,
1176            },
1177            DataModel {
1178                name: "knora-api".into(),
1179                iri: "http://api.knora.org/ontology/knora-api/v2".into(),
1180                label: None,
1181                last_modified: None,
1182                is_builtin: true,
1183            },
1184        ]
1185    }
1186
1187    #[test]
1188    fn data_models_prose_no_filter() {
1189        let out = SharedBuf::new();
1190        let mut renderer = ProseRenderer::with_writer(out.clone());
1191        let view = DataModelListView { items: make_data_model_fixture(), total: 3, filter: None };
1192        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1193        renderer.data_models(&view, &meta).unwrap();
1194
1195        let s = out.string();
1196        // Header with builtin suffix
1197        assert!(
1198            s.contains("data-models on https://api.test.dasch.swiss (3), incl. built-ins:"),
1199            "header must include count and built-ins suffix; got:\n{s}"
1200        );
1201        // dsp-cli/ADR-0007 footer on stdout
1202        assert!(s.contains("[anonymous on https://api.test.dasch.swiss]"));
1203        // beol row: label present + date (date portion only)
1204        assert!(s.contains("beol") && s.contains("The BEOL data-model") && s.contains("2024-05-27"));
1205        // Full RFC3339 timestamp must NOT appear in prose
1206        assert!(!s.contains("T13:43:26"), "prose must show only the date portion");
1207        // biblio row: no label, not builtin, so empty label slot
1208        assert!(s.contains("biblio"));
1209        // knora-api row: no label, is_builtin → "(built-in)" marker
1210        assert!(s.contains("(built-in)"));
1211        // "None" must never appear literally
1212        assert!(!s.contains("None"));
1213    }
1214
1215    #[test]
1216    fn data_models_prose_with_filter() {
1217        let out = SharedBuf::new();
1218        let mut renderer = ProseRenderer::with_writer(out.clone());
1219        let items = vec![make_data_model_fixture().remove(0)]; // just beol
1220        let view = DataModelListView { items, total: 3, filter: Some("beol".to_string()) };
1221        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1222        renderer.data_models(&view, &meta).unwrap();
1223
1224        let s = out.string();
1225        assert!(
1226            s.contains("(1 of 3 matching \"beol\")"),
1227            "filter header must show m of total; got:\n{s}"
1228        );
1229        // No builtins in filtered view → no suffix
1230        assert!(!s.contains("incl. built-ins"));
1231    }
1232
1233    /// The `data_models` method must NOT append `, incl. built-ins` to the header
1234    /// when the view contains no builtins. This is the complement of
1235    /// `data_models_prose_no_filter` (which uses a fixture with a builtin).
1236    #[test]
1237    fn data_models_prose_no_builtins_header_has_no_builtin_suffix() {
1238        let out = SharedBuf::new();
1239        let mut renderer = ProseRenderer::with_writer(out.clone());
1240        let view = DataModelListView {
1241            items: vec![
1242                DataModel {
1243                    name: "beol".into(),
1244                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1245                    label: Some("The BEOL data-model".into()),
1246                    last_modified: None,
1247                    is_builtin: false,
1248                },
1249                DataModel {
1250                    name: "biblio".into(),
1251                    iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1252                    label: None,
1253                    last_modified: None,
1254                    is_builtin: false,
1255                },
1256            ],
1257            total: 2,
1258            filter: None,
1259        };
1260        let meta = make_meta("anonymous", "api.dasch.swiss");
1261        renderer.data_models(&view, &meta).unwrap();
1262
1263        let s = out.string();
1264        // Header must end with "(2):" — without the ", incl. built-ins" suffix.
1265        let header_line = s.lines().next().expect("output must have at least one line");
1266        assert!(
1267            header_line.ends_with("(2):"),
1268            "header must end with '(2):' when there are no builtins; got: {header_line:?}"
1269        );
1270        assert!(
1271            !s.contains("incl. built-ins"),
1272            "header must not contain 'incl. built-ins' when no builtin items are present; \
1273             got:\n{s}"
1274        );
1275    }
1276
1277    #[test]
1278    fn data_models_prose_last_modified_no_t_separator() {
1279        // last_modified without a 'T' separator — fallback to whole string, no panic.
1280        // The value must contain no uppercase 'T' so split_once('T') returns None.
1281        let out = SharedBuf::new();
1282        let mut renderer = ProseRenderer::with_writer(out.clone());
1283        let view = DataModelListView {
1284            items: vec![DataModel {
1285                name: "odd".into(),
1286                iri: "http://example.org/odd".into(),
1287                label: None,
1288                last_modified: Some("2024-05-27 no-separator".into()),
1289                is_builtin: false,
1290            }],
1291            total: 1,
1292            filter: None,
1293        };
1294        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1295        renderer.data_models(&view, &meta).unwrap();
1296
1297        let s = out.string();
1298        // Whole string falls through when there is no 'T' separator
1299        assert!(
1300            s.contains("2024-05-27 no-separator"),
1301            "expected full string when no 'T' separator; got:\n{s}"
1302        );
1303    }
1304
1305    // ── data_model_describe prose tests ──────────────────────────────────────
1306
1307    fn make_beol_dm_detail() -> DataModelDetail {
1308        DataModelDetail {
1309            name: "beol".into(),
1310            iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1311            label: Some("The BEOL data-model".into()),
1312            last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1313            resource_types: vec![
1314                ResourceTypeSummary {
1315                    name: "Archive".into(),
1316                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1317                    label: Some("Archive".into()),
1318                },
1319                ResourceTypeSummary {
1320                    name: "basicLetter".into(),
1321                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
1322                    label: None,
1323                },
1324                ResourceTypeSummary {
1325                    name: "letter".into(),
1326                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1327                    label: Some("Letter".into()),
1328                },
1329            ],
1330        }
1331    }
1332
1333    #[test]
1334    fn data_model_describe_prose_full() {
1335        let out = SharedBuf::new();
1336        let mut renderer = ProseRenderer::with_writer(out.clone());
1337        let meta = make_meta("anonymous", "api.dasch.swiss");
1338        renderer.data_model_describe(&make_beol_dm_detail(), &meta).unwrap();
1339
1340        let s = out.string();
1341        // Header
1342        assert!(s.contains("Data-model: beol"), "header missing; got:\n{s}");
1343        // Label line present
1344        assert!(
1345            s.contains("Label:          The BEOL data-model"),
1346            "label line missing; got:\n{s}"
1347        );
1348        // IRI line always present
1349        assert!(
1350            s.contains("IRI:            http://api.dasch.swiss/ontology/0801/beol/v2"),
1351            "IRI line missing; got:\n{s}"
1352        );
1353        // Last-modified: date prefix only
1354        assert!(
1355            s.contains("Last-modified:  2024-05-27"),
1356            "last-modified line missing; got:\n{s}"
1357        );
1358        assert!(
1359            !s.contains("T13:43"),
1360            "prose must show only date portion, not full timestamp; got:\n{s}"
1361        );
1362        // Resource-types section header
1363        assert!(s.contains("Resource-types (3):"), "resource-types header missing; got:\n{s}");
1364        // Resource-type rows
1365        assert!(s.contains("Archive"), "Archive row missing; got:\n{s}");
1366        assert!(s.contains("basicLetter"), "basicLetter row missing; got:\n{s}");
1367        assert!(s.contains("Letter"), "Letter label missing; got:\n{s}");
1368        // dsp-cli/ADR-0007 footer
1369        assert!(s.contains("[anonymous on api.dasch.swiss]"), "footer missing; got:\n{s}");
1370        // "None" must never appear
1371        assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1372    }
1373
1374    #[test]
1375    fn data_model_describe_prose_no_label_no_last_modified() {
1376        let out = SharedBuf::new();
1377        let mut renderer = ProseRenderer::with_writer(out.clone());
1378        let detail = DataModelDetail {
1379            name: "minimal".into(),
1380            iri: "http://api.dasch.swiss/ontology/0000/minimal/v2".into(),
1381            label: None,
1382            last_modified: None,
1383            resource_types: vec![ResourceTypeSummary {
1384                name: "Thing".into(),
1385                iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#Thing".into(),
1386                label: None,
1387            }],
1388        };
1389        let meta = make_meta("anonymous", "api.dasch.swiss");
1390        renderer.data_model_describe(&detail, &meta).unwrap();
1391
1392        let s = out.string();
1393        // Label line must be absent when None
1394        assert!(
1395            !s.contains("Label:"),
1396            "Label: line must be absent when label is None; got:\n{s}"
1397        );
1398        // Last-modified line must be absent when None
1399        assert!(
1400            !s.contains("Last-modified:"),
1401            "Last-modified: line must be absent when last_modified is None; got:\n{s}"
1402        );
1403        // IRI still present
1404        assert!(s.contains("IRI:"), "IRI line must always be present; got:\n{s}");
1405        // None must not appear literally
1406        assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1407    }
1408
1409    #[test]
1410    fn data_model_describe_prose_zero_resource_types() {
1411        let out = SharedBuf::new();
1412        let mut renderer = ProseRenderer::with_writer(out.clone());
1413        let detail = DataModelDetail {
1414            name: "empty".into(),
1415            iri: "http://api.dasch.swiss/ontology/9999/empty/v2".into(),
1416            label: None,
1417            last_modified: None,
1418            resource_types: vec![],
1419        };
1420        let meta = make_meta("anonymous", "api.dasch.swiss");
1421        renderer.data_model_describe(&detail, &meta).unwrap();
1422
1423        let s = out.string();
1424        // Zero branch: "Resource-types (0)" without a colon
1425        assert!(
1426            s.contains("  Resource-types (0)"),
1427            "zero resource-types branch missing; got:\n{s}"
1428        );
1429        // Must NOT have a colon after "(0)" — that would imply a sub-list follows
1430        let rt_line = s
1431            .lines()
1432            .find(|l| l.contains("Resource-types (0)"))
1433            .expect("must have resource-types line");
1434        assert!(
1435            !rt_line.contains("Resource-types (0):"),
1436            "zero branch must NOT have a colon; got: {rt_line:?}"
1437        );
1438    }
1439
1440    #[test]
1441    fn data_model_describe_prose_last_modified_no_t_separator() {
1442        let out = SharedBuf::new();
1443        let mut renderer = ProseRenderer::with_writer(out.clone());
1444        let detail = DataModelDetail {
1445            name: "odd".into(),
1446            iri: "http://api.dasch.swiss/ontology/0000/odd/v2".into(),
1447            label: None,
1448            last_modified: Some("2024-05-27 no-separator".into()),
1449            resource_types: vec![],
1450        };
1451        let meta = make_meta("anonymous", "api.dasch.swiss");
1452        renderer.data_model_describe(&detail, &meta).unwrap();
1453
1454        let s = out.string();
1455        // Whole string falls through when there is no 'T' separator
1456        assert!(
1457            s.contains("2024-05-27 no-separator"),
1458            "expected full string when no 'T' separator; got:\n{s}"
1459        );
1460    }
1461
1462    // ── resource_types prose tests ────────────────────────────────────────────
1463
1464    fn make_resource_type_fixture() -> Vec<ResourceType> {
1465        vec![
1466            ResourceType {
1467                name: "Archive".into(),
1468                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1469                label: Some("Archive".into()),
1470                is_builtin: false,
1471                count: None,
1472            },
1473            ResourceType {
1474                name: "letter".into(),
1475                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1476                label: Some("Letter".into()),
1477                is_builtin: false,
1478                count: None,
1479            },
1480        ]
1481    }
1482
1483    #[test]
1484    fn resource_types_prose_project_only() {
1485        let out = SharedBuf::new();
1486        let mut renderer = ProseRenderer::with_writer(out.clone());
1487        let view = ResourceTypeListView {
1488            items: make_resource_type_fixture(),
1489            total: 2,
1490            filter: None,
1491            data_model: "beol".into(),
1492        };
1493        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1494        renderer.resource_types(&view, &meta).unwrap();
1495
1496        let s = out.string();
1497        // Header: no builtin suffix for project-only list
1498        assert!(
1499            s.contains("resource-types in beol on https://api.test.dasch.swiss (2):"),
1500            "header missing or incorrect; got:\n{s}"
1501        );
1502        assert!(
1503            !s.contains("incl. built-ins"),
1504            "must not have builtin suffix when no builtins; got:\n{s}"
1505        );
1506        // Rows present
1507        assert!(s.contains("Archive"), "Archive row missing; got:\n{s}");
1508        assert!(s.contains("letter"), "letter row missing; got:\n{s}");
1509        assert!(s.contains("Letter"), "Letter label missing; got:\n{s}");
1510        // No (built-in) marker
1511        assert!(
1512            !s.contains("(built-in)"),
1513            "must not have (built-in) marker on project-only items; got:\n{s}"
1514        );
1515        // dsp-cli/ADR-0007 footer on stdout
1516        assert!(
1517            s.contains("[anonymous on https://api.test.dasch.swiss]"),
1518            "footer missing; got:\n{s}"
1519        );
1520        // "None" must not appear
1521        assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1522    }
1523
1524    #[test]
1525    fn resource_types_prose_with_builtins() {
1526        let out = SharedBuf::new();
1527        let mut renderer = ProseRenderer::with_writer(out.clone());
1528        let mut items = make_resource_type_fixture();
1529        items.push(ResourceType {
1530            name: "Region".into(),
1531            iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1532            label: Some("Region".into()),
1533            is_builtin: true,
1534            count: None,
1535        });
1536        let view = ResourceTypeListView { items, total: 3, filter: None, data_model: "beol".into() };
1537        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1538        renderer.resource_types(&view, &meta).unwrap();
1539
1540        let s = out.string();
1541        // Header includes builtin suffix
1542        assert!(
1543            s.contains("resource-types in beol on https://api.test.dasch.swiss (3), incl. built-ins:"),
1544            "header with builtin suffix missing; got:\n{s}"
1545        );
1546        // Region row must have (built-in) marker
1547        assert!(s.contains("(built-in)"), "(built-in) marker missing on built-in row; got:\n{s}");
1548        // Project rows must NOT have (built-in) marker
1549        let archive_line = s.lines().find(|l| l.contains("Archive")).unwrap();
1550        assert!(
1551            !archive_line.contains("(built-in)"),
1552            "project row must not have (built-in) marker; got: {archive_line:?}"
1553        );
1554    }
1555
1556    #[test]
1557    fn resource_types_prose_with_filter() {
1558        let out = SharedBuf::new();
1559        let mut renderer = ProseRenderer::with_writer(out.clone());
1560        let items = vec![make_resource_type_fixture().remove(0)]; // just Archive
1561        let view = ResourceTypeListView {
1562            items,
1563            total: 3,
1564            filter: Some("arch".to_string()),
1565            data_model: "beol".into(),
1566        };
1567        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1568        renderer.resource_types(&view, &meta).unwrap();
1569
1570        let s = out.string();
1571        assert!(
1572            s.contains("(1 of 3 matching \"arch\")"),
1573            "filter header must show m of total; got:\n{s}"
1574        );
1575    }
1576
1577    #[test]
1578    fn resource_types_prose_empty() {
1579        let out = SharedBuf::new();
1580        let mut renderer = ProseRenderer::with_writer(out.clone());
1581        let view = ResourceTypeListView {
1582            items: vec![],
1583            total: 0,
1584            filter: None,
1585            data_model: "beol".into(),
1586        };
1587        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1588        renderer.resource_types(&view, &meta).unwrap();
1589
1590        let s = out.string();
1591        // Header still shows data_model name even when empty
1592        assert!(
1593            s.contains("resource-types in beol on https://api.test.dasch.swiss (0):"),
1594            "header must show data_model name even when empty; got:\n{s}"
1595        );
1596        // Footer still present
1597        assert!(
1598            s.contains("[anonymous on https://api.test.dasch.swiss]"),
1599            "footer missing; got:\n{s}"
1600        );
1601    }
1602
1603    #[test]
1604    fn resource_types_prose_with_counts() {
1605        // plan 030: when at least one item carries a count, a right-aligned
1606        // count column appears (both project and built-in rows), and the
1607        // count_caveat is appended to the dsp-cli/ADR-0007 footer suffix.
1608        let out = SharedBuf::new();
1609        let mut renderer = ProseRenderer::with_writer(out.clone());
1610        let mut items = make_resource_type_fixture();
1611        items[0].count = Some(42);
1612        items[1].count = Some(7);
1613        items.push(ResourceType {
1614            name: "Region".into(),
1615            iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1616            label: Some("Region".into()),
1617            is_builtin: true,
1618            count: None,
1619        });
1620        let view = ResourceTypeListView { items, total: 3, filter: None, data_model: "beol".into() };
1621        let meta = MetaContext {
1622            server_label: "https://api.test.dasch.swiss".into(),
1623            auth_state: "anonymous".into(),
1624            filter_warning: None,
1625            count_caveat: Some("counts are not permission-filtered".into()),
1626            count_cost: None,
1627        };
1628        renderer.resource_types(&view, &meta).unwrap();
1629
1630        let s = out.string();
1631        let archive_line = s.lines().find(|l| l.contains("Archive")).unwrap();
1632        assert!(
1633            archive_line.contains("42"),
1634            "Archive row must show count 42; got: {archive_line:?}"
1635        );
1636        let letter_line = s.lines().find(|l| l.contains("letter")).unwrap();
1637        assert!(letter_line.contains('7'), "letter row must show count 7; got: {letter_line:?}");
1638        // Built-in row with no count: count cell renders blank, marker still present.
1639        let region_line = s.lines().find(|l| l.contains("Region")).unwrap();
1640        assert!(
1641            region_line.contains("(built-in)"),
1642            "built-in marker must still be present; got: {region_line:?}"
1643        );
1644        // Disclosure footer carries the count_caveat.
1645        assert!(
1646            s.contains("counts are not permission-filtered"),
1647            "footer must carry count_caveat; got:\n{s}"
1648        );
1649    }
1650
1651    // ── resource_type_describe prose tests ───────────────────────────────────
1652
1653    /// Build a full beol manuscript fixture with representative fields:
1654    /// a text field (own DM), a link field (own DM), a cross-DM field
1655    /// (from biblio), a still-image representation, and an Extends.
1656    fn make_manuscript_detail() -> ResourceTypeDetail {
1657        ResourceTypeDetail {
1658            name: "manuscript".into(),
1659            iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1660            label: Some("Manuscript".into()),
1661            data_model: "beol".into(),
1662            representation: Some(Representation::StillImage),
1663            super_types: vec!["writtenSource".into()],
1664            fields: vec![
1665                Field {
1666                    name: "title".into(),
1667                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#title".into(),
1668                    label: Some("Title".into()),
1669                    value_type: ValueType::Text,
1670                    link_target: None,
1671                    cardinality: Cardinality::OneOrMore,
1672                    is_builtin: false,
1673                    data_model: Some("beol".into()),
1674                },
1675                Field {
1676                    name: "hasAuthor".into(),
1677                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#hasAuthor".into(),
1678                    label: Some("Author".into()),
1679                    value_type: ValueType::Link,
1680                    link_target: Some("person".into()),
1681                    cardinality: Cardinality::ZeroOrMore,
1682                    is_builtin: false,
1683                    data_model: Some("beol".into()),
1684                },
1685                Field {
1686                    name: "isPartOfCollection".into(),
1687                    iri: "http://api.dasch.swiss/ontology/0801/biblio/v2#isPartOfCollection".into(),
1688                    label: Some("is part of".into()),
1689                    value_type: ValueType::Link,
1690                    link_target: Some("Collection".into()),
1691                    cardinality: Cardinality::ZeroOrMore,
1692                    is_builtin: false,
1693                    data_model: Some("biblio".into()),
1694                },
1695            ],
1696            count: None,
1697        }
1698    }
1699
1700    #[test]
1701    fn resource_type_describe_prose_full() {
1702        let out = SharedBuf::new();
1703        let mut renderer = ProseRenderer::with_writer(out.clone());
1704        let meta = make_meta("anonymous", "https://api.dasch.swiss");
1705        renderer.resource_type_describe(&make_manuscript_detail(), &meta).unwrap();
1706
1707        let s = out.string();
1708        // Header
1709        assert!(s.contains("Resource-type: manuscript"), "header missing; got:\n{s}");
1710        // Label line present
1711        assert!(s.contains("Label:          Manuscript"), "label line missing; got:\n{s}");
1712        // Extends line present (non-empty super_types)
1713        assert!(s.contains("Extends:        writtenSource"), "Extends line missing; got:\n{s}");
1714        // Representation line present (Some)
1715        assert!(
1716            s.contains("Representation: still-image"),
1717            "Representation line missing; got:\n{s}"
1718        );
1719        // IRI line always present
1720        assert!(
1721            s.contains("IRI:            http://api.dasch.swiss/ontology/0801/beol/v2#manuscript"),
1722            "IRI line missing; got:\n{s}"
1723        );
1724        // Data-model line
1725        assert!(s.contains("Data-model:     beol"), "Data-model line missing; got:\n{s}");
1726        // Fields header
1727        assert!(s.contains("Fields (3):"), "Fields header missing; got:\n{s}");
1728        // Title row (own DM — no source tag)
1729        let title_line = s.lines().find(|l| l.contains("title")).unwrap();
1730        assert!(
1731            title_line.contains("text") && title_line.contains("1-n"),
1732            "title row incomplete; got: {title_line:?}"
1733        );
1734        assert!(
1735            !title_line.contains("[from"),
1736            "title row must not have [from ...] tag; got: {title_line:?}"
1737        );
1738        // Link field with arrow notation
1739        let author_line = s.lines().find(|l| l.contains("hasAuthor")).unwrap();
1740        assert!(
1741            author_line.contains("\u{2192} person"),
1742            "link field must show '→ person'; got: {author_line:?}"
1743        );
1744        // Cross-DM source tag
1745        let coll_line = s.lines().find(|l| l.contains("isPartOfCollection")).unwrap();
1746        assert!(
1747            coll_line.contains("[from biblio]"),
1748            "cross-DM field must show [from biblio]; got: {coll_line:?}"
1749        );
1750        // dsp-cli/ADR-0007 footer
1751        assert!(
1752            s.contains("[anonymous on https://api.dasch.swiss]"),
1753            "footer missing; got:\n{s}"
1754        );
1755        // "None" must never appear
1756        assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1757    }
1758
1759    #[test]
1760    fn resource_type_describe_prose_with_count() {
1761        // plan 030: `Instances:` line appears right after `Data-model:` when
1762        // `detail.count` is Some, and the count_caveat is carried in the footer.
1763        let out = SharedBuf::new();
1764        let mut renderer = ProseRenderer::with_writer(out.clone());
1765        let mut detail = make_manuscript_detail();
1766        detail.count = Some(123);
1767        let meta = MetaContext {
1768            server_label: "https://api.dasch.swiss".into(),
1769            auth_state: "anonymous".into(),
1770            filter_warning: None,
1771            count_caveat: Some("counts exclude deleted resources".into()),
1772            count_cost: None,
1773        };
1774        renderer.resource_type_describe(&detail, &meta).unwrap();
1775
1776        let s = out.string();
1777        assert!(
1778            s.contains("Instances:      123"),
1779            "Instances line missing or misaligned; got:\n{s}"
1780        );
1781        // Instances line comes right after Data-model line.
1782        let dm_idx = s.find("Data-model:").expect("Data-model line present");
1783        let inst_idx = s.find("Instances:").expect("Instances line present");
1784        assert!(inst_idx > dm_idx, "Instances line must come after Data-model line");
1785        assert!(
1786            s.contains("counts exclude deleted resources"),
1787            "footer must carry count_caveat; got:\n{s}"
1788        );
1789    }
1790
1791    #[test]
1792    fn resource_type_describe_prose_no_count_no_instances_line() {
1793        // Regression: count: None (today's only production case) must not
1794        // emit an Instances line.
1795        let out = SharedBuf::new();
1796        let mut renderer = ProseRenderer::with_writer(out.clone());
1797        let meta = make_meta("anonymous", "https://api.dasch.swiss");
1798        renderer.resource_type_describe(&make_manuscript_detail(), &meta).unwrap();
1799
1800        let s = out.string();
1801        assert!(
1802            !s.contains("Instances:"),
1803            "Instances line must be absent when count is None; got:\n{s}"
1804        );
1805    }
1806
1807    #[test]
1808    fn resource_type_describe_prose_no_label() {
1809        let out = SharedBuf::new();
1810        let mut renderer = ProseRenderer::with_writer(out.clone());
1811        let mut detail = make_manuscript_detail();
1812        detail.label = None;
1813        let meta = make_meta("anonymous", "api.dasch.swiss");
1814        renderer.resource_type_describe(&detail, &meta).unwrap();
1815
1816        let s = out.string();
1817        // Label line must be absent
1818        assert!(
1819            !s.contains("Label:"),
1820            "Label: line must be absent when label is None; got:\n{s}"
1821        );
1822        // Header still present
1823        assert!(s.contains("Resource-type: manuscript"), "header missing; got:\n{s}");
1824        // "None" must not appear
1825        assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1826    }
1827
1828    #[test]
1829    fn resource_type_describe_prose_zero_fields() {
1830        let out = SharedBuf::new();
1831        let mut renderer = ProseRenderer::with_writer(out.clone());
1832        let detail = ResourceTypeDetail {
1833            name: "Empty".into(),
1834            iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#Empty".into(),
1835            label: None,
1836            data_model: "minimal".into(),
1837            representation: None,
1838            super_types: vec![],
1839            fields: vec![],
1840            count: None,
1841        };
1842        let meta = make_meta("anonymous", "api.dasch.swiss");
1843        renderer.resource_type_describe(&detail, &meta).unwrap();
1844
1845        let s = out.string();
1846        // Zero branch: "Fields (0)" without colon, no rows
1847        assert!(s.contains("  Fields (0)"), "zero fields branch missing; got:\n{s}");
1848        // Must NOT have a colon after "(0)"
1849        let fields_line = s.lines().find(|l| l.contains("Fields (0)")).expect("must have Fields line");
1850        assert!(
1851            !fields_line.contains("Fields (0):"),
1852            "zero branch must NOT have a colon; got: {fields_line:?}"
1853        );
1854        // No Extends or Representation lines
1855        assert!(!s.contains("Extends:"), "Extends line must be absent; got:\n{s}");
1856        assert!(!s.contains("Representation:"), "Representation line must be absent; got:\n{s}");
1857    }
1858
1859    #[test]
1860    fn resource_type_describe_prose_no_representation() {
1861        let out = SharedBuf::new();
1862        let mut renderer = ProseRenderer::with_writer(out.clone());
1863        let mut detail = make_manuscript_detail();
1864        detail.representation = None;
1865        let meta = make_meta("anonymous", "api.dasch.swiss");
1866        renderer.resource_type_describe(&detail, &meta).unwrap();
1867
1868        let s = out.string();
1869        // Representation line must be absent
1870        assert!(
1871            !s.contains("Representation:"),
1872            "Representation: line must be absent when representation is None; got:\n{s}"
1873        );
1874    }
1875
1876    #[test]
1877    fn resource_type_describe_prose_include_builtins_marker() {
1878        // A field with is_builtin=true must get a trailing " (built-in)" marker.
1879        let out = SharedBuf::new();
1880        let mut renderer = ProseRenderer::with_writer(out.clone());
1881        let detail = ResourceTypeDetail {
1882            name: "thing".into(),
1883            iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#thing".into(),
1884            label: None,
1885            data_model: "minimal".into(),
1886            representation: None,
1887            super_types: vec![],
1888            fields: vec![
1889                Field {
1890                    name: "hasText".into(),
1891                    iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#hasText".into(),
1892                    label: Some("Text content".into()),
1893                    value_type: ValueType::Text,
1894                    link_target: None,
1895                    cardinality: Cardinality::One,
1896                    is_builtin: false,
1897                    data_model: Some("minimal".into()),
1898                },
1899                Field {
1900                    name: "arkUrl".into(),
1901                    iri: "http://api.knora.org/ontology/knora-api/v2#arkUrl".into(),
1902                    label: Some("ARK URL".into()),
1903                    value_type: ValueType::Uri,
1904                    link_target: None,
1905                    cardinality: Cardinality::One,
1906                    is_builtin: true,
1907                    data_model: None,
1908                },
1909            ],
1910            count: None,
1911        };
1912        let meta = make_meta("anonymous", "api.dasch.swiss");
1913        renderer.resource_type_describe(&detail, &meta).unwrap();
1914
1915        let s = out.string();
1916        // Project field must NOT have (built-in) marker
1917        let text_line = s.lines().find(|l| l.contains("hasText")).unwrap();
1918        assert!(
1919            !text_line.contains("(built-in)"),
1920            "project field must not have (built-in) marker; got: {text_line:?}"
1921        );
1922        // Built-in field must have (built-in) marker
1923        let ark_line = s.lines().find(|l| l.contains("arkUrl")).unwrap();
1924        assert!(
1925            ark_line.contains("(built-in)"),
1926            "built-in field must have (built-in) marker; got: {ark_line:?}"
1927        );
1928    }
1929
1930    #[test]
1931    fn resource_type_describe_prose_extends_multiple_supers() {
1932        // Multiple super_types → joined with ", "
1933        let out = SharedBuf::new();
1934        let mut renderer = ProseRenderer::with_writer(out.clone());
1935        let detail = ResourceTypeDetail {
1936            name: "letter".into(),
1937            iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1938            label: Some("Letter".into()),
1939            data_model: "beol".into(),
1940            representation: None,
1941            super_types: vec!["basicLetter".into(), "writtenSource".into()],
1942            fields: vec![],
1943            count: None,
1944        };
1945        let meta = make_meta("anonymous", "api.dasch.swiss");
1946        renderer.resource_type_describe(&detail, &meta).unwrap();
1947
1948        let s = out.string();
1949        assert!(
1950            s.contains("Extends:        basicLetter, writtenSource"),
1951            "multiple supers must be joined with ', '; got:\n{s}"
1952        );
1953    }
1954
1955    // ── resource_describe prose values tests ─────────────────────────────────
1956
1957    use crate::model::{
1958        DatePoint, DateValue, FieldValues, FileValue, ResourceAccess, ResourceDetail, ResourceVisibility, Value,
1959        ValueContent,
1960    };
1961
1962    fn make_resource_detail_no_values() -> ResourceDetail {
1963        ResourceDetail {
1964            label: "Test Resource".into(),
1965            iri: "http://rdfh.ch/0803/abc123".into(),
1966            resource_type: "Page".into(),
1967            ark_url: Some("ark:/72163/1/0803/abc123".into()),
1968            creation_date: Some("2021-01-01T00:00:00Z".into()),
1969            last_modified: None,
1970            attached_project: Some("http://rdfh.ch/projects/0803".into()),
1971            owner: Some("http://rdfh.ch/users/alice".into()),
1972            visibility: Some(ResourceVisibility::Public),
1973            your_access: Some(ResourceAccess::View),
1974            values: None,
1975        }
1976    }
1977
1978    #[test]
1979    fn resource_describe_prose_no_values_unchanged() {
1980        // When values is None, output must be byte-identical to 8b behaviour —
1981        // no "Values:" section.
1982        let out = SharedBuf::new();
1983        let mut renderer = ProseRenderer::with_writer(out.clone());
1984        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1985        renderer.resource_describe(&make_resource_detail_no_values(), &meta).unwrap();
1986
1987        let s = out.string();
1988        assert!(s.contains("Resource: Test Resource"), "header missing");
1989        assert!(!s.contains("Values:"), "Values: section must be absent when values is None");
1990    }
1991
1992    #[test]
1993    fn resource_describe_prose_some_empty_values() {
1994        // When values is Some(vec![]), render "Values: (none)"
1995        let out = SharedBuf::new();
1996        let mut renderer = ProseRenderer::with_writer(out.clone());
1997        let mut detail = make_resource_detail_no_values();
1998        detail.values = Some(vec![]);
1999        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2000        renderer.resource_describe(&detail, &meta).unwrap();
2001
2002        let s = out.string();
2003        assert!(
2004            s.contains("Values: (none)"),
2005            "empty values must render as 'Values: (none)'; got:\n{s}"
2006        );
2007    }
2008
2009    #[test]
2010    fn resource_describe_prose_values_section() {
2011        // Smoke test: verify Values: section is present and basic field rendering works.
2012        let out = SharedBuf::new();
2013        let mut renderer = ProseRenderer::with_writer(out.clone());
2014        let mut detail = make_resource_detail_no_values();
2015        detail.values = Some(vec![
2016            FieldValues {
2017                name: "hasTitle".into(),
2018                label: Some("Title".into()),
2019                values: vec![ValueContent::Text("Incunabula Page".into()).into()],
2020            },
2021            FieldValues {
2022                name: "seqnum".into(),
2023                label: None, // degraded label — name only, no parens
2024                values: vec![ValueContent::Integer(42).into()],
2025            },
2026        ]);
2027        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2028        renderer.resource_describe(&detail, &meta).unwrap();
2029
2030        let s = out.string();
2031        assert!(s.contains("Values:"), "Values: header missing; got:\n{s}");
2032        // Field with label: "<label> (<name>)"
2033        assert!(s.contains("Title (hasTitle)"), "labelled field header format wrong; got:\n{s}");
2034        assert!(s.contains("Incunabula Page"), "text value missing; got:\n{s}");
2035        // Field without label: "<name>" only, no parentheses
2036        let seqnum_line = s.lines().find(|l| l.contains("seqnum")).unwrap();
2037        assert!(
2038            !seqnum_line.contains('('),
2039            "unlabelled field must not have parentheses; got: {seqnum_line:?}"
2040        );
2041        assert!(s.contains("42"), "integer value missing; got:\n{s}");
2042    }
2043
2044    #[test]
2045    fn resource_describe_prose_value_with_comment() {
2046        // A value carrying a `knora-api:valueHasComment` renders an indented
2047        // "comment: <text>" line directly under the value line.
2048        let out = SharedBuf::new();
2049        let mut renderer = ProseRenderer::with_writer(out.clone());
2050        let mut detail = make_resource_detail_no_values();
2051        detail.values = Some(vec![FieldValues {
2052            name: "hasTranscription".into(),
2053            label: Some("Transcription".into()),
2054            values: vec![Value {
2055                content: ValueContent::Text("some transcription".into()),
2056                comment: Some("reading uncertain".into()),
2057            }],
2058        }]);
2059        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2060        renderer.resource_describe(&detail, &meta).unwrap();
2061
2062        let s = out.string();
2063        assert!(s.contains("some transcription"), "value line missing; got:\n{s}");
2064        assert!(
2065            s.contains("      comment: reading uncertain"),
2066            "comment line missing or mis-indented; got:\n{s}"
2067        );
2068        // Comment line must immediately follow the value line.
2069        let lines: Vec<&str> = s.lines().collect();
2070        let value_idx = lines.iter().position(|l| l.contains("some transcription")).unwrap();
2071        assert_eq!(
2072            lines[value_idx + 1],
2073            "      comment: reading uncertain",
2074            "comment line must directly follow the value line; got:\n{s}"
2075        );
2076    }
2077
2078    #[test]
2079    fn resource_describe_prose_value_without_comment_renders_no_comment_line() {
2080        // A value with `comment: None` must NOT emit a "comment: " line at all
2081        // (not an empty one) — the companion negative case to
2082        // `resource_describe_prose_value_with_comment`.
2083        let out = SharedBuf::new();
2084        let mut renderer = ProseRenderer::with_writer(out.clone());
2085        let mut detail = make_resource_detail_no_values();
2086        detail.values = Some(vec![FieldValues {
2087            name: "hasTranscription".into(),
2088            label: Some("Transcription".into()),
2089            values: vec![ValueContent::Text("some transcription".into()).into()],
2090        }]);
2091        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2092        renderer.resource_describe(&detail, &meta).unwrap();
2093
2094        let s = out.string();
2095        assert!(s.contains("some transcription"), "value line missing; got:\n{s}");
2096        assert!(
2097            !s.contains("comment:"),
2098            "no comment line must be emitted when value.comment is None; got:\n{s}"
2099        );
2100    }
2101
2102    #[test]
2103    fn resource_describe_prose_link_with_label() {
2104        let out = SharedBuf::new();
2105        let mut renderer = ProseRenderer::with_writer(out.clone());
2106        let mut detail = make_resource_detail_no_values();
2107        detail.values = Some(vec![FieldValues {
2108            name: "isPartOf".into(),
2109            label: Some("Is part of".into()),
2110            values: vec![
2111                ValueContent::Link {
2112                    target_iri: "http://rdfh.ch/0803/book1".into(),
2113                    target_label: Some("Incunabula Book".into()),
2114                }
2115                .into(),
2116                ValueContent::Link {
2117                    target_iri: "http://rdfh.ch/0803/book2".into(),
2118                    target_label: None, // degraded link — no brackets
2119                }
2120                .into(),
2121            ],
2122        }]);
2123        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2124        renderer.resource_describe(&detail, &meta).unwrap();
2125
2126        let s = out.string();
2127        // Link with label: "→ <label> [<iri>]"
2128        assert!(
2129            s.contains("\u{2192} Incunabula Book [http://rdfh.ch/0803/book1]"),
2130            "link with label rendered incorrectly; got:\n{s}"
2131        );
2132        // Link without label: "→ <iri>" (no brackets)
2133        assert!(
2134            s.contains("\u{2192} http://rdfh.ch/0803/book2"),
2135            "degraded link (no label) rendered incorrectly; got:\n{s}"
2136        );
2137        // No square brackets around the degraded link
2138        let degraded_line = s.lines().find(|l| l.contains("http://rdfh.ch/0803/book2")).unwrap();
2139        assert!(
2140            !degraded_line.contains('['),
2141            "degraded link must not have brackets; got: {degraded_line:?}"
2142        );
2143    }
2144
2145    #[test]
2146    fn resource_describe_prose_file_still_image_with_dims() {
2147        let out = SharedBuf::new();
2148        let mut renderer = ProseRenderer::with_writer(out.clone());
2149        let mut detail = make_resource_detail_no_values();
2150        detail.values = Some(vec![FieldValues {
2151            name: "hasStillImageFileValue".into(),
2152            label: None,
2153            values: vec![
2154                ValueContent::File(FileValue {
2155                    value_type: ValueType::StillImage,
2156                    filename: "image.jp2".into(),
2157                    url: "https://iiif.example.com/image.jp2/full/max/0/default.jpg".into(),
2158                    width: Some(1200),
2159                    height: Some(800),
2160                })
2161                .into(),
2162            ],
2163        }]);
2164        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2165        renderer.resource_describe(&detail, &meta).unwrap();
2166
2167        let s = out.string();
2168        // Still-image: "<filename> (<W>×<H>) <url>"
2169        assert!(
2170            s.contains("image.jp2 (1200\u{d7}800) https://iiif.example.com"),
2171            "still-image with dims rendered incorrectly; got:\n{s}"
2172        );
2173    }
2174
2175    #[test]
2176    fn resource_describe_prose_date_rendering() {
2177        let out = SharedBuf::new();
2178        let mut renderer = ProseRenderer::with_writer(out.clone());
2179        let mut detail = make_resource_detail_no_values();
2180        // Single-point date (start == end)
2181        let pt = DatePoint {
2182            year: Some(1489),
2183            month: None,
2184            day: None,
2185            era: Some("CE".into()),
2186        };
2187        let single = DateValue {
2188            calendar: "GREGORIAN".into(),
2189            start: pt.clone(),
2190            end: pt.clone(),
2191        };
2192        // Range date
2193        let range = DateValue {
2194            calendar: "GREGORIAN".into(),
2195            start: DatePoint {
2196                year: Some(1489),
2197                month: None,
2198                day: None,
2199                era: Some("CE".into()),
2200            },
2201            end: DatePoint {
2202                year: Some(1490),
2203                month: None,
2204                day: None,
2205                era: Some("CE".into()),
2206            },
2207        };
2208        detail.values = Some(vec![FieldValues {
2209            name: "hasDate".into(),
2210            label: Some("Date".into()),
2211            values: vec![ValueContent::Date(single).into(), ValueContent::Date(range).into()],
2212        }]);
2213        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2214        renderer.resource_describe(&detail, &meta).unwrap();
2215
2216        let s = out.string();
2217        // Single-point date
2218        assert!(
2219            s.contains("1489 CE (GREGORIAN)"),
2220            "single-point date rendered incorrectly; got:\n{s}"
2221        );
2222        // Range date with en-dash
2223        assert!(
2224            s.contains("1489 CE \u{2013} 1490 CE (GREGORIAN)"),
2225            "range date rendered incorrectly; got:\n{s}"
2226        );
2227    }
2228
2229    // ── resource label control-char sanitisation (Phase 8.5 #1) ───────────────
2230
2231    #[test]
2232    fn resource_describe_prose_strips_control_chars_in_label() {
2233        // A resource (instance) label carrying control characters must not reach
2234        // the terminal raw — terminal-injection / display hardening.
2235        let out = SharedBuf::new();
2236        let mut renderer = ProseRenderer::with_writer(out.clone());
2237        let mut detail = make_resource_detail_no_values();
2238        detail.label = "Bad\u{1b}[31mLabel\u{7f}".into();
2239        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2240        renderer.resource_describe(&detail, &meta).unwrap();
2241
2242        let s = out.string();
2243        assert!(
2244            s.contains("Resource: Bad[31mLabel"),
2245            "label control chars not stripped; got:\n{s}"
2246        );
2247        assert!(
2248            !s.contains('\u{1b}') && !s.contains('\u{7f}'),
2249            "control characters leaked into prose output; got:\n{s:?}"
2250        );
2251    }
2252
2253    #[test]
2254    fn resources_prose_strips_control_chars_in_label() {
2255        use crate::model::ResourceSummary;
2256
2257        let out = SharedBuf::new();
2258        let mut renderer = ProseRenderer::with_writer(out.clone());
2259        let view = ResourceListView {
2260            items: vec![ResourceSummary {
2261                label: "Bad\u{1b}[31mLabel\u{7f}".into(),
2262                iri: "http://rdfh.ch/0803/abc123".into(),
2263                ark_url: None,
2264                creation_date: None,
2265                last_modified: None,
2266                resource_type: "Page".into(),
2267            }],
2268            total: 1,
2269            filter: None,
2270            resource_type: "Page".into(),
2271            pagination: ResourceListPagination::SinglePage { page: 0, may_have_more: false },
2272        };
2273        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2274        renderer.resources(&view, &meta).unwrap();
2275
2276        let s = out.string();
2277        assert!(s.contains("Bad[31mLabel"), "label control chars not stripped; got:\n{s}");
2278        assert!(
2279            !s.contains('\u{1b}') && !s.contains('\u{7f}'),
2280            "control characters leaked into prose output; got:\n{s:?}"
2281        );
2282    }
2283}