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