Skip to main content

dsp_cli/render/
prose.rs

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