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 has_counts = view.items.iter().any(|rt| rt.count.is_some());
498        let builtin_suffix = if has_builtins {
499            ", incl. built-ins"
500        } else {
501            ""
502        };
503
504        // Header line: with or without filter.
505        match &view.filter {
506            None => writeln!(
507                self.out,
508                "resource-types in {} on {} ({n}){builtin_suffix}:",
509                view.data_model, meta.server_label,
510            )?,
511            Some(f) => writeln!(
512                self.out,
513                "resource-types in {} on {} ({n} of {} matching \"{f}\"){builtin_suffix}:",
514                view.data_model, meta.server_label, view.total,
515            )?,
516        }
517
518        writeln!(self.out)?;
519
520        // Compute column widths for alignment.
521        let name_w = view.items.iter().map(|rt| rt.name.len()).max().unwrap_or(0);
522        let label_w = view
523            .items
524            .iter()
525            .map(|rt| rt.label.as_deref().map(|l| l.len()).unwrap_or(0))
526            .max()
527            .unwrap_or(0);
528
529        if has_counts {
530            // Right-aligned COUNT column, shown only when at least one item
531            // carries a count (plan 030). label is now padded in both branches
532            // so the count column aligns whether or not a row is built-in.
533            let count_w = view
534                .items
535                .iter()
536                .map(|rt| rt.count.map(|c| c.to_string()).unwrap_or_default().len())
537                .max()
538                .unwrap_or(0);
539            for item in &view.items {
540                let label = item.label.as_deref().unwrap_or("");
541                let count_str = item.count.map(|c| c.to_string()).unwrap_or_default();
542                if item.is_builtin {
543                    writeln!(
544                        self.out,
545                        "  {:<name_w$}  {:<label_w$}  {:>count_w$}  (built-in)",
546                        item.name, label, count_str,
547                    )?;
548                } else {
549                    writeln!(
550                        self.out,
551                        "  {:<name_w$}  {:<label_w$}  {:>count_w$}",
552                        item.name, label, count_str,
553                    )?;
554                }
555            }
556        } else {
557            for item in &view.items {
558                let label = item.label.as_deref().unwrap_or("");
559                // Built-in rows get an explicit trailing marker; project rows get nothing.
560                if item.is_builtin {
561                    writeln!(
562                        self.out,
563                        "  {:<name_w$}  {:<label_w$}  (built-in)",
564                        item.name, label,
565                    )?;
566                } else {
567                    writeln!(self.out, "  {:<name_w$}  {label}", item.name)?;
568                }
569            }
570        }
571
572        render_prose_footer(&mut *self.out, meta)?;
573        Ok(())
574    }
575
576    fn resource_type_describe(
577        &mut self,
578        detail: &crate::model::ResourceTypeDetail,
579        meta: &MetaContext,
580    ) -> Result<(), Diagnostic> {
581        use crate::model::ValueType;
582
583        // Header: "Resource-type: <name>"
584        writeln!(self.out, "Resource-type: {}", detail.name)?;
585
586        // Optional label/extends/representation header block — 2-space indent,
587        // values aligned to a common column (longest label key is "Representation:"
588        // at 15 chars + 2-space prefix + 2-space gap = col 19).
589        if let Some(ref lbl) = detail.label {
590            writeln!(self.out, "  Label:          {lbl}")?;
591        }
592        if !detail.super_types.is_empty() {
593            writeln!(
594                self.out,
595                "  Extends:        {}",
596                detail.super_types.join(", ")
597            )?;
598        }
599        if let Some(ref repr) = detail.representation {
600            writeln!(self.out, "  Representation: {repr}")?;
601        }
602        writeln!(self.out, "  IRI:            {}", detail.iri)?;
603        writeln!(self.out, "  Data-model:     {}", detail.data_model)?;
604        if let Some(count) = detail.count {
605            writeln!(self.out, "  Instances:      {count}")?;
606        }
607
608        // Fields block.
609        let n = detail.fields.len();
610        if n == 0 {
611            writeln!(self.out, "\n  Fields (0)")?;
612        } else {
613            writeln!(self.out, "\n  Fields ({n}):")?;
614
615            // Build rendered value-type strings (link → Target or plain value-type).
616            let vtype_strs: Vec<String> = detail
617                .fields
618                .iter()
619                .map(|f| match &f.value_type {
620                    ValueType::Link => {
621                        if let Some(ref tgt) = f.link_target {
622                            format!("link \u{2192} {tgt}")
623                        } else {
624                            "link".to_string()
625                        }
626                    }
627                    vt => vt.to_string(),
628                })
629                .collect();
630
631            // Compute column widths.
632            let name_w = detail
633                .fields
634                .iter()
635                .map(|f| f.name.len())
636                .max()
637                .unwrap_or(0);
638            let vtype_w = vtype_strs.iter().map(|s| s.len()).max().unwrap_or(0);
639            let card_w = detail
640                .fields
641                .iter()
642                .map(|f| f.cardinality.to_string().len())
643                .max()
644                .unwrap_or(0);
645
646            for (field, vtype_str) in detail.fields.iter().zip(vtype_strs.iter()) {
647                let label = field.label.as_deref().unwrap_or("");
648                let card_str = field.cardinality.to_string();
649
650                // Source tag: append when field's data_model is Some and differs from
651                // the resource-type's own data_model.
652                let source_tag = if let Some(ref dm) = field.data_model {
653                    if dm != &detail.data_model {
654                        format!("  [from {dm}]")
655                    } else {
656                        String::new()
657                    }
658                } else {
659                    String::new()
660                };
661
662                // Built-in marker: append after label when is_builtin.
663                let builtin_marker = if field.is_builtin { "  (built-in)" } else { "" };
664
665                writeln!(
666                    self.out,
667                    "    {:<name_w$}  {:<vtype_w$}  {:<card_w$}  {label}{builtin_marker}{source_tag}",
668                    field.name, vtype_str, card_str,
669                )?;
670            }
671        }
672
673        render_prose_footer(&mut *self.out, meta)?;
674        Ok(())
675    }
676
677    fn data_model_structure(
678        &mut self,
679        structure: &DataModelStructure,
680        meta: &MetaContext,
681    ) -> Result<(), Diagnostic> {
682        use crate::model::RelationKind;
683
684        // Header: "Structure: <dm>  (<n> relations)"
685        let n = structure.relations.len();
686        let plural = if n == 1 { "relation" } else { "relations" };
687        writeln!(
688            self.out,
689            "Structure: {}  ({n} {plural})",
690            structure.data_model
691        )?;
692
693        if n > 0 {
694            writeln!(self.out)?;
695
696            // Compute column widths.
697            // source_w: max source name length.
698            let source_w = structure
699                .relations
700                .iter()
701                .map(|r| r.source.len())
702                .max()
703                .unwrap_or(0);
704
705            // target_w: max of (target + optional " [to <dm>]") length.
706            let target_w = structure
707                .relations
708                .iter()
709                .map(|r| {
710                    let tag_len = match &r.target_data_model {
711                        Some(dm) if dm != &structure.data_model => {
712                            " [to ".len() + dm.len() + "]".len()
713                        }
714                        _ => 0,
715                    };
716                    r.target.len() + tag_len
717                })
718                .max()
719                .unwrap_or(0);
720
721            // field_w: max field name length (empty string for inherits).
722            let field_w = structure
723                .relations
724                .iter()
725                .map(|r| r.field.as_deref().unwrap_or("").len())
726                .max()
727                .unwrap_or(0);
728
729            for rel in &structure.relations {
730                // Build the target column: target + optional "[to <dm>]" tag.
731                let target_col = match &rel.target_data_model {
732                    Some(dm) if dm != &structure.data_model => {
733                        format!("{} [to {}]", rel.target, dm)
734                    }
735                    _ => rel.target.clone(),
736                };
737
738                // field column: field name for link, empty for inherits.
739                let field_col = rel.field.as_deref().unwrap_or("");
740
741                // kind marker.
742                let kind_marker = match rel.kind {
743                    RelationKind::Link => "[link]",
744                    RelationKind::Inherits => "[inherits]",
745                };
746
747                writeln!(
748                    self.out,
749                    "  {:<source_w$}   \u{2192} {:<target_w$}   {:<field_w$}   {kind_marker}",
750                    rel.source, target_col, field_col,
751                )?;
752            }
753        }
754
755        render_prose_footer(&mut *self.out, meta)?;
756        Ok(())
757    }
758
759    fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
760        let n = view.items.len();
761
762        // Header line: with or without filter.
763        match &view.filter {
764            None => writeln!(
765                self.out,
766                "resources of type {} on {} ({n}):",
767                view.resource_type, meta.server_label,
768            )?,
769            Some(f) => writeln!(
770                self.out,
771                "resources of type {} on {} ({n} of {} matching \"{f}\"):",
772                view.resource_type, meta.server_label, view.total,
773            )?,
774        }
775
776        if n > 0 {
777            writeln!(self.out)?;
778
779            // Sanitise labels before computing the alignment width, so padding
780            // stays correct when a label contains control characters (stripping
781            // shortens the byte length the `{:<label_w$}` pad relies on).
782            let labels: Vec<String> = view
783                .items
784                .iter()
785                .map(|r| strip_control_chars(&r.label))
786                .collect();
787            let label_w = labels.iter().map(|l| l.len()).max().unwrap_or(0);
788
789            for (item, label) in view.items.iter().zip(&labels) {
790                writeln!(self.out, "  {:<label_w$}  {}", label, item.iri,)?;
791            }
792        }
793
794        // "more results available" hint — only in SinglePage mode when may_have_more.
795        if let ResourceListPagination::SinglePage {
796            may_have_more: true,
797            ..
798        } = view.pagination
799        {
800            writeln!(self.out)?;
801            writeln!(
802                self.out,
803                "  more results available (use --all to fetch all pages)"
804            )?;
805        }
806
807        render_prose_footer(&mut *self.out, meta)?;
808        Ok(())
809    }
810
811    fn resource_describe(
812        &mut self,
813        detail: &crate::model::ResourceDetail,
814        meta: &MetaContext,
815    ) -> Result<(), Diagnostic> {
816        // Header: "Resource: <label>"
817        writeln!(self.out, "Resource: {}", strip_control_chars(&detail.label))?;
818
819        // Label/value block — 2-space indent, values aligned to a common column.
820        // Longest label key is "Your access:" (12 chars) + 2-space prefix + gap.
821        // Column alignment: pad to 14 chars for the label (incl. colon).
822        // "Your access:  " = 12 + 2 = 14 wide.
823        writeln!(self.out, "  Type:         {}", detail.resource_type)?;
824        writeln!(self.out, "  IRI:          {}", detail.iri)?;
825        if let Some(ref ark) = detail.ark_url {
826            writeln!(self.out, "  ARK:          {ark}")?;
827        }
828        if let Some(ref created) = detail.creation_date {
829            writeln!(self.out, "  Created:      {created}")?;
830        }
831        if let Some(ref modified) = detail.last_modified {
832            writeln!(self.out, "  Modified:     {modified}")?;
833        }
834        if let Some(ref project) = detail.attached_project {
835            writeln!(self.out, "  Project:      {project}")?;
836        }
837        if let Some(ref owner) = detail.owner {
838            writeln!(self.out, "  Owner:        {owner}")?;
839        }
840        if let Some(ref vis) = detail.visibility {
841            writeln!(self.out, "  Visibility:   {}", vis.as_str())?;
842        }
843        if let Some(ref access) = detail.your_access {
844            writeln!(self.out, "  Your access:  {}", access.as_str())?;
845        }
846
847        // Values section — only when `--values` was set (detail.values is Some).
848        if let Some(ref fields) = detail.values {
849            writeln!(self.out)?;
850            if fields.is_empty() {
851                writeln!(self.out, "Values: (none)")?;
852            } else {
853                writeln!(self.out, "Values:")?;
854                for fv in fields {
855                    // Field header: "<label> (<name>)" when label is Some, else "<name>".
856                    let header = match &fv.label {
857                        Some(lbl) => {
858                            format!(
859                                "{} ({})",
860                                strip_control_chars(lbl),
861                                strip_control_chars(&fv.name)
862                            )
863                        }
864                        None => strip_control_chars(&fv.name),
865                    };
866                    writeln!(self.out, "  {header}")?;
867
868                    for value in &fv.values {
869                        let rendered = render_value_content(&value.content);
870                        writeln!(self.out, "    {}", strip_control_chars(&rendered))?;
871                        if let Some(comment) = &value.comment {
872                            writeln!(self.out, "      comment: {}", strip_control_chars(comment))?;
873                        }
874                    }
875                }
876            }
877        }
878
879        render_prose_footer(&mut *self.out, meta)?;
880        Ok(())
881    }
882}
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887    use crate::model::{
888        Cardinality, DataModel, DataModelDetail, DataModelSummary, Field, Project,
889        ProjectDescription, ProjectDetail, ProjectStatus, Representation, ResourceType,
890        ResourceTypeDetail, ResourceTypeSummary, ValueType,
891    };
892    use crate::render::test_support::{SharedBuf, make_meta};
893    use crate::render::{DataModelListView, ResourceTypeListView};
894
895    fn make_fixture() -> Vec<Project> {
896        vec![
897            Project {
898                iri: "http://rdfh.ch/projects/0001".into(),
899                shortcode: "0001".into(),
900                shortname: "anything".into(),
901                longname: Some("Anything Project".into()),
902                status: ProjectStatus::Active,
903                data_models: 2,
904            },
905            Project {
906                iri: "http://rdfh.ch/projects/0002".into(),
907                shortcode: "0002".into(),
908                shortname: "images".into(),
909                longname: None,
910                status: ProjectStatus::Inactive,
911                data_models: 0,
912            },
913            Project {
914                iri: "http://rdfh.ch/projects/0803".into(),
915                shortcode: "0803".into(),
916                shortname: "daschland".into(),
917                longname: Some("=Formula Project".into()),
918                status: ProjectStatus::Active,
919                data_models: 1,
920            },
921        ]
922    }
923
924    #[test]
925    fn projects_prose_no_filter() {
926        let out = SharedBuf::new();
927        let mut renderer = ProseRenderer::with_writer(out.clone());
928        let view = ProjectListView {
929            items: make_fixture(),
930            total: 3,
931            filter: None,
932        };
933        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
934        renderer.projects(&view, &meta).unwrap();
935
936        let s = out.string();
937        assert!(s.contains("Projects on https://api.test.dasch.swiss (3):"));
938        // Footer on stdout (not stderr)
939        assert!(s.contains("[anonymous on https://api.test.dasch.swiss]"));
940        // IRI is omitted
941        assert!(!s.contains("rdfh.ch"));
942        // data_models line for active/2 project
943        assert!(s.contains("2 data-models"));
944        // singular for 1 data-model
945        assert!(s.contains("1 data-model"));
946        // no data-models line for 0
947        let data_model_lines = s.lines().filter(|l| l.contains("data-model")).count();
948        assert_eq!(data_model_lines, 2);
949        // None longname → empty (no "None" printed)
950        assert!(!s.contains("None"));
951    }
952
953    #[test]
954    fn projects_prose_with_filter() {
955        let out = SharedBuf::new();
956        let mut renderer = ProseRenderer::with_writer(out.clone());
957        let items = vec![make_fixture().remove(0)]; // just the "anything" project
958        let view = ProjectListView {
959            items,
960            total: 3,
961            filter: Some("any".to_string()),
962        };
963        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
964        renderer.projects(&view, &meta).unwrap();
965
966        let s = out.string();
967        assert!(s.contains("(1 of 3 matching \"any\")"));
968    }
969
970    #[test]
971    fn projects_prose_footer_on_stdout() {
972        // Prose writes the disclosure footer to stdout (not stderr).
973        let out = SharedBuf::new();
974        let mut renderer = ProseRenderer::with_writer(out.clone());
975        let view = ProjectListView {
976            items: vec![],
977            total: 0,
978            filter: None,
979        };
980        let meta = make_meta("authenticated as alice", "https://api.dasch.swiss");
981        renderer.projects(&view, &meta).unwrap();
982
983        let s = out.string();
984        assert!(s.contains("[authenticated as alice on https://api.dasch.swiss]"));
985    }
986
987    fn make_beol_detail() -> ProjectDetail {
988        ProjectDetail {
989            iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
990            shortcode: "0801".into(),
991            shortname: "beol".into(),
992            longname: Some("Bernoulli-Euler Online".into()),
993            status: ProjectStatus::Active,
994            description: vec![ProjectDescription {
995                value: "<b>BEOL</b> — early modern mathematics.".into(),
996                language: Some("en".into()),
997            }],
998            keywords: vec!["Bernoulli".into(), "Euler".into(), "Mathematics".into()],
999            data_models: vec![
1000                DataModelSummary {
1001                    name: "beol".into(),
1002                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1003                },
1004                DataModelSummary {
1005                    name: "biblio".into(),
1006                    iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1007                },
1008                DataModelSummary {
1009                    name: "leibniz".into(),
1010                    iri: "http://api.dasch.swiss/ontology/0801/leibniz/v2".into(),
1011                },
1012                DataModelSummary {
1013                    name: "newton".into(),
1014                    iri: "http://api.dasch.swiss/ontology/0801/newton/v2".into(),
1015                },
1016            ],
1017        }
1018    }
1019
1020    #[test]
1021    fn project_describe_prose_full() {
1022        let out = SharedBuf::new();
1023        let mut renderer = ProseRenderer::with_writer(out.clone());
1024        let meta = make_meta("anonymous", "api.dasch.swiss");
1025        renderer
1026            .project_describe(&make_beol_detail(), &meta)
1027            .unwrap();
1028
1029        let s = out.string();
1030        // Header: shortname (shortcode) — new layout B
1031        assert!(s.contains("Project: beol (0801)"));
1032        // Longname is now a labeled field, not in the title
1033        assert!(s.contains("Name:       Bernoulli-Euler Online"));
1034        // Other label/value lines
1035        assert!(s.contains("IRI:        http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF"));
1036        assert!(s.contains("Status:     active"));
1037        assert!(s.contains("Keywords:   Bernoulli, Euler, Mathematics"));
1038        // Data-models with count + names
1039        assert!(s.contains("Data-models (4): beol, biblio, leibniz, newton"));
1040        // Description: plain text — no raw HTML tags, language prefix present
1041        assert!(s.contains("[en] BEOL"));
1042        assert!(
1043            !s.contains("<b>"),
1044            "description must not contain raw <b> tags"
1045        );
1046        assert!(
1047            !s.contains("</b>"),
1048            "description must not contain raw </b> tags"
1049        );
1050        // ADR-0007 footer
1051        assert!(s.contains("[anonymous on api.dasch.swiss]"));
1052    }
1053
1054    #[test]
1055    fn project_describe_prose_no_longname() {
1056        let out = SharedBuf::new();
1057        let mut renderer = ProseRenderer::with_writer(out.clone());
1058        let mut detail = make_beol_detail();
1059        detail.longname = None;
1060        let meta = make_meta("anonymous", "api.dasch.swiss");
1061        renderer.project_describe(&detail, &meta).unwrap();
1062
1063        let s = out.string();
1064        // Header is shortname (shortcode); no Name: line
1065        assert!(s.contains("Project: beol (0801)"));
1066        assert!(
1067            !s.contains("Name:"),
1068            "Name: line must be absent when longname is None"
1069        );
1070    }
1071
1072    #[test]
1073    fn project_describe_prose_empty_keywords_and_description() {
1074        let out = SharedBuf::new();
1075        let mut renderer = ProseRenderer::with_writer(out.clone());
1076        let detail = ProjectDetail {
1077            iri: "http://rdfh.ch/projects/0000".into(),
1078            shortcode: "0000".into(),
1079            shortname: "minimal".into(),
1080            longname: None,
1081            status: ProjectStatus::Inactive,
1082            description: vec![],
1083            keywords: vec![],
1084            data_models: vec![],
1085        };
1086        let meta = make_meta("anonymous", "api.dasch.swiss");
1087        renderer.project_describe(&detail, &meta).unwrap();
1088
1089        let s = out.string();
1090        // Keywords line omitted
1091        assert!(!s.contains("Keywords"));
1092        // Description block omitted
1093        assert!(!s.contains("Description"));
1094        // Data-models (0) with no names
1095        assert!(s.contains("Data-models (0)"));
1096        assert!(!s.contains("Data-models (0):"));
1097    }
1098
1099    #[test]
1100    fn project_describe_prose_description_no_language() {
1101        let out = SharedBuf::new();
1102        let mut renderer = ProseRenderer::with_writer(out.clone());
1103        let detail = ProjectDetail {
1104            iri: "http://rdfh.ch/projects/0001".into(),
1105            shortcode: "0001".into(),
1106            shortname: "test".into(),
1107            longname: None,
1108            status: ProjectStatus::Active,
1109            description: vec![ProjectDescription {
1110                value: "Plain description without language tag.".into(),
1111                language: None,
1112            }],
1113            keywords: vec![],
1114            data_models: vec![],
1115        };
1116        let meta = make_meta("anonymous", "api.dasch.swiss");
1117        renderer.project_describe(&detail, &meta).unwrap();
1118
1119        let s = out.string();
1120        // No language prefix on description line — value rendered with 4-space indent.
1121        assert!(s.contains("    Plain description without language tag."));
1122        // The description line itself must not have a language tag prefix like "[en]".
1123        let desc_line = s.lines().find(|l| l.contains("Plain description")).unwrap();
1124        assert!(!desc_line.contains('['));
1125    }
1126
1127    /// Two description entries: one with a language tag, one without.
1128    /// Plan: "Multiple description entries: render each, prefixing with `[<lang>]`
1129    /// only when `language` is set."
1130    #[test]
1131    fn project_describe_prose_two_descriptions_language_and_none() {
1132        let out = SharedBuf::new();
1133        let mut renderer = ProseRenderer::with_writer(out.clone());
1134        let detail = ProjectDetail {
1135            iri: "http://rdfh.ch/projects/0002".into(),
1136            shortcode: "0002".into(),
1137            shortname: "multilang".into(),
1138            longname: None,
1139            status: ProjectStatus::Active,
1140            description: vec![
1141                ProjectDescription {
1142                    value: "English description of this project.".into(),
1143                    language: Some("en".into()),
1144                },
1145                ProjectDescription {
1146                    value: "Description without a language tag.".into(),
1147                    language: None,
1148                },
1149            ],
1150            keywords: vec![],
1151            data_models: vec![],
1152        };
1153        let meta = make_meta("anonymous", "api.dasch.swiss");
1154        renderer.project_describe(&detail, &meta).unwrap();
1155
1156        let s = out.string();
1157
1158        // Both entries must appear in the rendered output.
1159        assert!(
1160            s.contains("English description of this project."),
1161            "first description value must appear"
1162        );
1163        assert!(
1164            s.contains("Description without a language tag."),
1165            "second description value must appear"
1166        );
1167
1168        // The languaged entry's value line must be prefixed with "[en] ".
1169        let en_line = s
1170            .lines()
1171            .find(|l| l.contains("English description"))
1172            .expect("expected a line containing 'English description'");
1173        assert!(
1174            en_line.contains("[en] "),
1175            "languaged entry must carry '[en] ' prefix; got: {en_line:?}"
1176        );
1177
1178        // The no-language entry's value line must NOT be prefixed with any "[...]".
1179        let plain_line = s
1180            .lines()
1181            .find(|l| l.contains("Description without a language tag."))
1182            .expect("expected a line containing 'Description without a language tag.'");
1183        assert!(
1184            !plain_line.contains('['),
1185            "no-language entry must not have a '[...]' prefix; got: {plain_line:?}"
1186        );
1187    }
1188
1189    /// A multi-paragraph description: blank separator lines between paragraphs
1190    /// must be rendered TRULY empty, not as whitespace-only (indented) lines.
1191    #[test]
1192    fn project_describe_prose_blank_lines_are_not_indented() {
1193        let out = SharedBuf::new();
1194        let mut renderer = ProseRenderer::with_writer(out.clone());
1195        let detail = ProjectDetail {
1196            iri: "http://rdfh.ch/projects/0003".into(),
1197            shortcode: "0003".into(),
1198            shortname: "multipara".into(),
1199            longname: None,
1200            status: ProjectStatus::Active,
1201            description: vec![ProjectDescription {
1202                value: "First paragraph.\n\nSecond paragraph.".into(),
1203                language: Some("en".into()),
1204            }],
1205            keywords: vec![],
1206            data_models: vec![],
1207        };
1208        let meta = make_meta("anonymous", "api.dasch.swiss");
1209        renderer.project_describe(&detail, &meta).unwrap();
1210
1211        let s = out.string();
1212        // Both paragraphs render (the continuation line is indented to align).
1213        assert!(s.contains("[en] First paragraph."));
1214        assert!(s.contains("Second paragraph."));
1215        // No line may be whitespace-only (non-empty but all-whitespace): the blank
1216        // separator between paragraphs must be a truly empty line.
1217        let ws_only = s.lines().find(|l| !l.is_empty() && l.trim().is_empty());
1218        assert!(
1219            ws_only.is_none(),
1220            "blank separator lines must be truly empty, not whitespace-only; got:\n{s}"
1221        );
1222    }
1223
1224    /// A description entry whose value reduces to empty text after html_to_text
1225    /// (e.g. only tags) must NOT leave a dangling `Description:` label.
1226    #[test]
1227    fn project_describe_prose_empty_after_html_omits_description_label() {
1228        let out = SharedBuf::new();
1229        let mut renderer = ProseRenderer::with_writer(out.clone());
1230        let detail = ProjectDetail {
1231            iri: "http://rdfh.ch/projects/0004".into(),
1232            shortcode: "0004".into(),
1233            shortname: "tagsonly".into(),
1234            longname: None,
1235            status: ProjectStatus::Active,
1236            description: vec![ProjectDescription {
1237                value: "<br/>".into(), // → "" after html_to_text
1238                language: Some("en".into()),
1239            }],
1240            keywords: vec![],
1241            data_models: vec![],
1242        };
1243        let meta = make_meta("anonymous", "api.dasch.swiss");
1244        renderer.project_describe(&detail, &meta).unwrap();
1245
1246        let s = out.string();
1247        assert!(
1248            !s.contains("Description:"),
1249            "Description: label must be omitted when no entry has visible text; got:\n{s}"
1250        );
1251    }
1252
1253    fn make_data_model_fixture() -> Vec<DataModel> {
1254        vec![
1255            DataModel {
1256                name: "beol".into(),
1257                iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1258                label: Some("The BEOL data-model".into()),
1259                last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1260                is_builtin: false,
1261            },
1262            DataModel {
1263                name: "biblio".into(),
1264                iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1265                label: None,
1266                last_modified: Some("2024-01-10T08:00:00.000000Z".into()),
1267                is_builtin: false,
1268            },
1269            DataModel {
1270                name: "knora-api".into(),
1271                iri: "http://api.knora.org/ontology/knora-api/v2".into(),
1272                label: None,
1273                last_modified: None,
1274                is_builtin: true,
1275            },
1276        ]
1277    }
1278
1279    #[test]
1280    fn data_models_prose_no_filter() {
1281        let out = SharedBuf::new();
1282        let mut renderer = ProseRenderer::with_writer(out.clone());
1283        let view = DataModelListView {
1284            items: make_data_model_fixture(),
1285            total: 3,
1286            filter: None,
1287        };
1288        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1289        renderer.data_models(&view, &meta).unwrap();
1290
1291        let s = out.string();
1292        // Header with builtin suffix
1293        assert!(
1294            s.contains("data-models on https://api.test.dasch.swiss (3), incl. built-ins:"),
1295            "header must include count and built-ins suffix; got:\n{s}"
1296        );
1297        // ADR-0007 footer on stdout
1298        assert!(s.contains("[anonymous on https://api.test.dasch.swiss]"));
1299        // beol row: label present + date (date portion only)
1300        assert!(
1301            s.contains("beol") && s.contains("The BEOL data-model") && s.contains("2024-05-27")
1302        );
1303        // Full RFC3339 timestamp must NOT appear in prose
1304        assert!(
1305            !s.contains("T13:43:26"),
1306            "prose must show only the date portion"
1307        );
1308        // biblio row: no label, not builtin, so empty label slot
1309        assert!(s.contains("biblio"));
1310        // knora-api row: no label, is_builtin → "(built-in)" marker
1311        assert!(s.contains("(built-in)"));
1312        // "None" must never appear literally
1313        assert!(!s.contains("None"));
1314    }
1315
1316    #[test]
1317    fn data_models_prose_with_filter() {
1318        let out = SharedBuf::new();
1319        let mut renderer = ProseRenderer::with_writer(out.clone());
1320        let items = vec![make_data_model_fixture().remove(0)]; // just beol
1321        let view = DataModelListView {
1322            items,
1323            total: 3,
1324            filter: Some("beol".to_string()),
1325        };
1326        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1327        renderer.data_models(&view, &meta).unwrap();
1328
1329        let s = out.string();
1330        assert!(
1331            s.contains("(1 of 3 matching \"beol\")"),
1332            "filter header must show m of total; got:\n{s}"
1333        );
1334        // No builtins in filtered view → no suffix
1335        assert!(!s.contains("incl. built-ins"));
1336    }
1337
1338    /// The `data_models` method must NOT append `, incl. built-ins` to the header
1339    /// when the view contains no builtins. This is the complement of
1340    /// `data_models_prose_no_filter` (which uses a fixture with a builtin).
1341    #[test]
1342    fn data_models_prose_no_builtins_header_has_no_builtin_suffix() {
1343        let out = SharedBuf::new();
1344        let mut renderer = ProseRenderer::with_writer(out.clone());
1345        let view = DataModelListView {
1346            items: vec![
1347                DataModel {
1348                    name: "beol".into(),
1349                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1350                    label: Some("The BEOL data-model".into()),
1351                    last_modified: None,
1352                    is_builtin: false,
1353                },
1354                DataModel {
1355                    name: "biblio".into(),
1356                    iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1357                    label: None,
1358                    last_modified: None,
1359                    is_builtin: false,
1360                },
1361            ],
1362            total: 2,
1363            filter: None,
1364        };
1365        let meta = make_meta("anonymous", "api.dasch.swiss");
1366        renderer.data_models(&view, &meta).unwrap();
1367
1368        let s = out.string();
1369        // Header must end with "(2):" — without the ", incl. built-ins" suffix.
1370        let header_line = s
1371            .lines()
1372            .next()
1373            .expect("output must have at least one line");
1374        assert!(
1375            header_line.ends_with("(2):"),
1376            "header must end with '(2):' when there are no builtins; got: {header_line:?}"
1377        );
1378        assert!(
1379            !s.contains("incl. built-ins"),
1380            "header must not contain 'incl. built-ins' when no builtin items are present; \
1381             got:\n{s}"
1382        );
1383    }
1384
1385    #[test]
1386    fn data_models_prose_last_modified_no_t_separator() {
1387        // last_modified without a 'T' separator — fallback to whole string, no panic.
1388        // The value must contain no uppercase 'T' so split_once('T') returns None.
1389        let out = SharedBuf::new();
1390        let mut renderer = ProseRenderer::with_writer(out.clone());
1391        let view = DataModelListView {
1392            items: vec![DataModel {
1393                name: "odd".into(),
1394                iri: "http://example.org/odd".into(),
1395                label: None,
1396                last_modified: Some("2024-05-27 no-separator".into()),
1397                is_builtin: false,
1398            }],
1399            total: 1,
1400            filter: None,
1401        };
1402        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1403        renderer.data_models(&view, &meta).unwrap();
1404
1405        let s = out.string();
1406        // Whole string falls through when there is no 'T' separator
1407        assert!(
1408            s.contains("2024-05-27 no-separator"),
1409            "expected full string when no 'T' separator; got:\n{s}"
1410        );
1411    }
1412
1413    // ── data_model_describe prose tests ──────────────────────────────────────
1414
1415    fn make_beol_dm_detail() -> DataModelDetail {
1416        DataModelDetail {
1417            name: "beol".into(),
1418            iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1419            label: Some("The BEOL data-model".into()),
1420            last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1421            resource_types: vec![
1422                ResourceTypeSummary {
1423                    name: "Archive".into(),
1424                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1425                    label: Some("Archive".into()),
1426                },
1427                ResourceTypeSummary {
1428                    name: "basicLetter".into(),
1429                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
1430                    label: None,
1431                },
1432                ResourceTypeSummary {
1433                    name: "letter".into(),
1434                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1435                    label: Some("Letter".into()),
1436                },
1437            ],
1438        }
1439    }
1440
1441    #[test]
1442    fn data_model_describe_prose_full() {
1443        let out = SharedBuf::new();
1444        let mut renderer = ProseRenderer::with_writer(out.clone());
1445        let meta = make_meta("anonymous", "api.dasch.swiss");
1446        renderer
1447            .data_model_describe(&make_beol_dm_detail(), &meta)
1448            .unwrap();
1449
1450        let s = out.string();
1451        // Header
1452        assert!(s.contains("Data-model: beol"), "header missing; got:\n{s}");
1453        // Label line present
1454        assert!(
1455            s.contains("Label:          The BEOL data-model"),
1456            "label line missing; got:\n{s}"
1457        );
1458        // IRI line always present
1459        assert!(
1460            s.contains("IRI:            http://api.dasch.swiss/ontology/0801/beol/v2"),
1461            "IRI line missing; got:\n{s}"
1462        );
1463        // Last-modified: date prefix only
1464        assert!(
1465            s.contains("Last-modified:  2024-05-27"),
1466            "last-modified line missing; got:\n{s}"
1467        );
1468        assert!(
1469            !s.contains("T13:43"),
1470            "prose must show only date portion, not full timestamp; got:\n{s}"
1471        );
1472        // Resource-types section header
1473        assert!(
1474            s.contains("Resource-types (3):"),
1475            "resource-types header missing; got:\n{s}"
1476        );
1477        // Resource-type rows
1478        assert!(s.contains("Archive"), "Archive row missing; got:\n{s}");
1479        assert!(
1480            s.contains("basicLetter"),
1481            "basicLetter row missing; got:\n{s}"
1482        );
1483        assert!(s.contains("Letter"), "Letter label missing; got:\n{s}");
1484        // ADR-0007 footer
1485        assert!(
1486            s.contains("[anonymous on api.dasch.swiss]"),
1487            "footer missing; got:\n{s}"
1488        );
1489        // "None" must never appear
1490        assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1491    }
1492
1493    #[test]
1494    fn data_model_describe_prose_no_label_no_last_modified() {
1495        let out = SharedBuf::new();
1496        let mut renderer = ProseRenderer::with_writer(out.clone());
1497        let detail = DataModelDetail {
1498            name: "minimal".into(),
1499            iri: "http://api.dasch.swiss/ontology/0000/minimal/v2".into(),
1500            label: None,
1501            last_modified: None,
1502            resource_types: vec![ResourceTypeSummary {
1503                name: "Thing".into(),
1504                iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#Thing".into(),
1505                label: None,
1506            }],
1507        };
1508        let meta = make_meta("anonymous", "api.dasch.swiss");
1509        renderer.data_model_describe(&detail, &meta).unwrap();
1510
1511        let s = out.string();
1512        // Label line must be absent when None
1513        assert!(
1514            !s.contains("Label:"),
1515            "Label: line must be absent when label is None; got:\n{s}"
1516        );
1517        // Last-modified line must be absent when None
1518        assert!(
1519            !s.contains("Last-modified:"),
1520            "Last-modified: line must be absent when last_modified is None; got:\n{s}"
1521        );
1522        // IRI still present
1523        assert!(
1524            s.contains("IRI:"),
1525            "IRI line must always be present; got:\n{s}"
1526        );
1527        // None must not appear literally
1528        assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1529    }
1530
1531    #[test]
1532    fn data_model_describe_prose_zero_resource_types() {
1533        let out = SharedBuf::new();
1534        let mut renderer = ProseRenderer::with_writer(out.clone());
1535        let detail = DataModelDetail {
1536            name: "empty".into(),
1537            iri: "http://api.dasch.swiss/ontology/9999/empty/v2".into(),
1538            label: None,
1539            last_modified: None,
1540            resource_types: vec![],
1541        };
1542        let meta = make_meta("anonymous", "api.dasch.swiss");
1543        renderer.data_model_describe(&detail, &meta).unwrap();
1544
1545        let s = out.string();
1546        // Zero branch: "Resource-types (0)" without a colon
1547        assert!(
1548            s.contains("  Resource-types (0)"),
1549            "zero resource-types branch missing; got:\n{s}"
1550        );
1551        // Must NOT have a colon after "(0)" — that would imply a sub-list follows
1552        let rt_line = s
1553            .lines()
1554            .find(|l| l.contains("Resource-types (0)"))
1555            .expect("must have resource-types line");
1556        assert!(
1557            !rt_line.contains("Resource-types (0):"),
1558            "zero branch must NOT have a colon; got: {rt_line:?}"
1559        );
1560    }
1561
1562    #[test]
1563    fn data_model_describe_prose_last_modified_no_t_separator() {
1564        let out = SharedBuf::new();
1565        let mut renderer = ProseRenderer::with_writer(out.clone());
1566        let detail = DataModelDetail {
1567            name: "odd".into(),
1568            iri: "http://api.dasch.swiss/ontology/0000/odd/v2".into(),
1569            label: None,
1570            last_modified: Some("2024-05-27 no-separator".into()),
1571            resource_types: vec![],
1572        };
1573        let meta = make_meta("anonymous", "api.dasch.swiss");
1574        renderer.data_model_describe(&detail, &meta).unwrap();
1575
1576        let s = out.string();
1577        // Whole string falls through when there is no 'T' separator
1578        assert!(
1579            s.contains("2024-05-27 no-separator"),
1580            "expected full string when no 'T' separator; got:\n{s}"
1581        );
1582    }
1583
1584    // ── resource_types prose tests ────────────────────────────────────────────
1585
1586    fn make_resource_type_fixture() -> Vec<ResourceType> {
1587        vec![
1588            ResourceType {
1589                name: "Archive".into(),
1590                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1591                label: Some("Archive".into()),
1592                is_builtin: false,
1593                count: None,
1594            },
1595            ResourceType {
1596                name: "letter".into(),
1597                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1598                label: Some("Letter".into()),
1599                is_builtin: false,
1600                count: None,
1601            },
1602        ]
1603    }
1604
1605    #[test]
1606    fn resource_types_prose_project_only() {
1607        let out = SharedBuf::new();
1608        let mut renderer = ProseRenderer::with_writer(out.clone());
1609        let view = ResourceTypeListView {
1610            items: make_resource_type_fixture(),
1611            total: 2,
1612            filter: None,
1613            data_model: "beol".into(),
1614        };
1615        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1616        renderer.resource_types(&view, &meta).unwrap();
1617
1618        let s = out.string();
1619        // Header: no builtin suffix for project-only list
1620        assert!(
1621            s.contains("resource-types in beol on https://api.test.dasch.swiss (2):"),
1622            "header missing or incorrect; got:\n{s}"
1623        );
1624        assert!(
1625            !s.contains("incl. built-ins"),
1626            "must not have builtin suffix when no builtins; got:\n{s}"
1627        );
1628        // Rows present
1629        assert!(s.contains("Archive"), "Archive row missing; got:\n{s}");
1630        assert!(s.contains("letter"), "letter row missing; got:\n{s}");
1631        assert!(s.contains("Letter"), "Letter label missing; got:\n{s}");
1632        // No (built-in) marker
1633        assert!(
1634            !s.contains("(built-in)"),
1635            "must not have (built-in) marker on project-only items; got:\n{s}"
1636        );
1637        // ADR-0007 footer on stdout
1638        assert!(
1639            s.contains("[anonymous on https://api.test.dasch.swiss]"),
1640            "footer missing; got:\n{s}"
1641        );
1642        // "None" must not appear
1643        assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1644    }
1645
1646    #[test]
1647    fn resource_types_prose_with_builtins() {
1648        let out = SharedBuf::new();
1649        let mut renderer = ProseRenderer::with_writer(out.clone());
1650        let mut items = make_resource_type_fixture();
1651        items.push(ResourceType {
1652            name: "Region".into(),
1653            iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1654            label: Some("Region".into()),
1655            is_builtin: true,
1656            count: None,
1657        });
1658        let view = ResourceTypeListView {
1659            items,
1660            total: 3,
1661            filter: None,
1662            data_model: "beol".into(),
1663        };
1664        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1665        renderer.resource_types(&view, &meta).unwrap();
1666
1667        let s = out.string();
1668        // Header includes builtin suffix
1669        assert!(
1670            s.contains(
1671                "resource-types in beol on https://api.test.dasch.swiss (3), incl. built-ins:"
1672            ),
1673            "header with builtin suffix missing; got:\n{s}"
1674        );
1675        // Region row must have (built-in) marker
1676        assert!(
1677            s.contains("(built-in)"),
1678            "(built-in) marker missing on built-in row; got:\n{s}"
1679        );
1680        // Project rows must NOT have (built-in) marker
1681        let archive_line = s.lines().find(|l| l.contains("Archive")).unwrap();
1682        assert!(
1683            !archive_line.contains("(built-in)"),
1684            "project row must not have (built-in) marker; got: {archive_line:?}"
1685        );
1686    }
1687
1688    #[test]
1689    fn resource_types_prose_with_filter() {
1690        let out = SharedBuf::new();
1691        let mut renderer = ProseRenderer::with_writer(out.clone());
1692        let items = vec![make_resource_type_fixture().remove(0)]; // just Archive
1693        let view = ResourceTypeListView {
1694            items,
1695            total: 3,
1696            filter: Some("arch".to_string()),
1697            data_model: "beol".into(),
1698        };
1699        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1700        renderer.resource_types(&view, &meta).unwrap();
1701
1702        let s = out.string();
1703        assert!(
1704            s.contains("(1 of 3 matching \"arch\")"),
1705            "filter header must show m of total; got:\n{s}"
1706        );
1707    }
1708
1709    #[test]
1710    fn resource_types_prose_empty() {
1711        let out = SharedBuf::new();
1712        let mut renderer = ProseRenderer::with_writer(out.clone());
1713        let view = ResourceTypeListView {
1714            items: vec![],
1715            total: 0,
1716            filter: None,
1717            data_model: "beol".into(),
1718        };
1719        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1720        renderer.resource_types(&view, &meta).unwrap();
1721
1722        let s = out.string();
1723        // Header still shows data_model name even when empty
1724        assert!(
1725            s.contains("resource-types in beol on https://api.test.dasch.swiss (0):"),
1726            "header must show data_model name even when empty; got:\n{s}"
1727        );
1728        // Footer still present
1729        assert!(
1730            s.contains("[anonymous on https://api.test.dasch.swiss]"),
1731            "footer missing; got:\n{s}"
1732        );
1733    }
1734
1735    #[test]
1736    fn resource_types_prose_with_counts() {
1737        // plan 030: when at least one item carries a count, a right-aligned
1738        // count column appears (both project and built-in rows), and the
1739        // count_caveat is appended to the ADR-0007 footer suffix.
1740        let out = SharedBuf::new();
1741        let mut renderer = ProseRenderer::with_writer(out.clone());
1742        let mut items = make_resource_type_fixture();
1743        items[0].count = Some(42);
1744        items[1].count = Some(7);
1745        items.push(ResourceType {
1746            name: "Region".into(),
1747            iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1748            label: Some("Region".into()),
1749            is_builtin: true,
1750            count: None,
1751        });
1752        let view = ResourceTypeListView {
1753            items,
1754            total: 3,
1755            filter: None,
1756            data_model: "beol".into(),
1757        };
1758        let meta = MetaContext {
1759            server_label: "https://api.test.dasch.swiss".into(),
1760            auth_state: "anonymous".into(),
1761            filter_warning: None,
1762            count_caveat: Some("counts are not permission-filtered".into()),
1763        };
1764        renderer.resource_types(&view, &meta).unwrap();
1765
1766        let s = out.string();
1767        let archive_line = s.lines().find(|l| l.contains("Archive")).unwrap();
1768        assert!(
1769            archive_line.contains("42"),
1770            "Archive row must show count 42; got: {archive_line:?}"
1771        );
1772        let letter_line = s.lines().find(|l| l.contains("letter")).unwrap();
1773        assert!(
1774            letter_line.contains('7'),
1775            "letter row must show count 7; got: {letter_line:?}"
1776        );
1777        // Built-in row with no count: count cell renders blank, marker still present.
1778        let region_line = s.lines().find(|l| l.contains("Region")).unwrap();
1779        assert!(
1780            region_line.contains("(built-in)"),
1781            "built-in marker must still be present; got: {region_line:?}"
1782        );
1783        // Disclosure footer carries the count_caveat.
1784        assert!(
1785            s.contains("counts are not permission-filtered"),
1786            "footer must carry count_caveat; got:\n{s}"
1787        );
1788    }
1789
1790    // ── resource_type_describe prose tests ───────────────────────────────────
1791
1792    /// Build a full beol manuscript fixture with representative fields:
1793    /// a text field (own DM), a link field (own DM), a cross-DM field
1794    /// (from biblio), a still-image representation, and an Extends.
1795    fn make_manuscript_detail() -> ResourceTypeDetail {
1796        ResourceTypeDetail {
1797            name: "manuscript".into(),
1798            iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1799            label: Some("Manuscript".into()),
1800            data_model: "beol".into(),
1801            representation: Some(Representation::StillImage),
1802            super_types: vec!["writtenSource".into()],
1803            fields: vec![
1804                Field {
1805                    name: "title".into(),
1806                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#title".into(),
1807                    label: Some("Title".into()),
1808                    value_type: ValueType::Text,
1809                    link_target: None,
1810                    cardinality: Cardinality::OneOrMore,
1811                    is_builtin: false,
1812                    data_model: Some("beol".into()),
1813                },
1814                Field {
1815                    name: "hasAuthor".into(),
1816                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#hasAuthor".into(),
1817                    label: Some("Author".into()),
1818                    value_type: ValueType::Link,
1819                    link_target: Some("person".into()),
1820                    cardinality: Cardinality::ZeroOrMore,
1821                    is_builtin: false,
1822                    data_model: Some("beol".into()),
1823                },
1824                Field {
1825                    name: "isPartOfCollection".into(),
1826                    iri: "http://api.dasch.swiss/ontology/0801/biblio/v2#isPartOfCollection".into(),
1827                    label: Some("is part of".into()),
1828                    value_type: ValueType::Link,
1829                    link_target: Some("Collection".into()),
1830                    cardinality: Cardinality::ZeroOrMore,
1831                    is_builtin: false,
1832                    data_model: Some("biblio".into()),
1833                },
1834            ],
1835            count: None,
1836        }
1837    }
1838
1839    #[test]
1840    fn resource_type_describe_prose_full() {
1841        let out = SharedBuf::new();
1842        let mut renderer = ProseRenderer::with_writer(out.clone());
1843        let meta = make_meta("anonymous", "https://api.dasch.swiss");
1844        renderer
1845            .resource_type_describe(&make_manuscript_detail(), &meta)
1846            .unwrap();
1847
1848        let s = out.string();
1849        // Header
1850        assert!(
1851            s.contains("Resource-type: manuscript"),
1852            "header missing; got:\n{s}"
1853        );
1854        // Label line present
1855        assert!(
1856            s.contains("Label:          Manuscript"),
1857            "label line missing; got:\n{s}"
1858        );
1859        // Extends line present (non-empty super_types)
1860        assert!(
1861            s.contains("Extends:        writtenSource"),
1862            "Extends line missing; got:\n{s}"
1863        );
1864        // Representation line present (Some)
1865        assert!(
1866            s.contains("Representation: still-image"),
1867            "Representation line missing; got:\n{s}"
1868        );
1869        // IRI line always present
1870        assert!(
1871            s.contains("IRI:            http://api.dasch.swiss/ontology/0801/beol/v2#manuscript"),
1872            "IRI line missing; got:\n{s}"
1873        );
1874        // Data-model line
1875        assert!(
1876            s.contains("Data-model:     beol"),
1877            "Data-model line missing; got:\n{s}"
1878        );
1879        // Fields header
1880        assert!(
1881            s.contains("Fields (3):"),
1882            "Fields header missing; got:\n{s}"
1883        );
1884        // Title row (own DM — no source tag)
1885        let title_line = s.lines().find(|l| l.contains("title")).unwrap();
1886        assert!(
1887            title_line.contains("text") && title_line.contains("1-n"),
1888            "title row incomplete; got: {title_line:?}"
1889        );
1890        assert!(
1891            !title_line.contains("[from"),
1892            "title row must not have [from ...] tag; got: {title_line:?}"
1893        );
1894        // Link field with arrow notation
1895        let author_line = s.lines().find(|l| l.contains("hasAuthor")).unwrap();
1896        assert!(
1897            author_line.contains("\u{2192} person"),
1898            "link field must show '→ person'; got: {author_line:?}"
1899        );
1900        // Cross-DM source tag
1901        let coll_line = s
1902            .lines()
1903            .find(|l| l.contains("isPartOfCollection"))
1904            .unwrap();
1905        assert!(
1906            coll_line.contains("[from biblio]"),
1907            "cross-DM field must show [from biblio]; got: {coll_line:?}"
1908        );
1909        // ADR-0007 footer
1910        assert!(
1911            s.contains("[anonymous on https://api.dasch.swiss]"),
1912            "footer missing; got:\n{s}"
1913        );
1914        // "None" must never appear
1915        assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1916    }
1917
1918    #[test]
1919    fn resource_type_describe_prose_with_count() {
1920        // plan 030: `Instances:` line appears right after `Data-model:` when
1921        // `detail.count` is Some, and the count_caveat is carried in the footer.
1922        let out = SharedBuf::new();
1923        let mut renderer = ProseRenderer::with_writer(out.clone());
1924        let mut detail = make_manuscript_detail();
1925        detail.count = Some(123);
1926        let meta = MetaContext {
1927            server_label: "https://api.dasch.swiss".into(),
1928            auth_state: "anonymous".into(),
1929            filter_warning: None,
1930            count_caveat: Some("counts exclude deleted resources".into()),
1931        };
1932        renderer.resource_type_describe(&detail, &meta).unwrap();
1933
1934        let s = out.string();
1935        assert!(
1936            s.contains("Instances:      123"),
1937            "Instances line missing or misaligned; got:\n{s}"
1938        );
1939        // Instances line comes right after Data-model line.
1940        let dm_idx = s.find("Data-model:").expect("Data-model line present");
1941        let inst_idx = s.find("Instances:").expect("Instances line present");
1942        assert!(
1943            inst_idx > dm_idx,
1944            "Instances line must come after Data-model line"
1945        );
1946        assert!(
1947            s.contains("counts exclude deleted resources"),
1948            "footer must carry count_caveat; got:\n{s}"
1949        );
1950    }
1951
1952    #[test]
1953    fn resource_type_describe_prose_no_count_no_instances_line() {
1954        // Regression: count: None (today's only production case) must not
1955        // emit an Instances line.
1956        let out = SharedBuf::new();
1957        let mut renderer = ProseRenderer::with_writer(out.clone());
1958        let meta = make_meta("anonymous", "https://api.dasch.swiss");
1959        renderer
1960            .resource_type_describe(&make_manuscript_detail(), &meta)
1961            .unwrap();
1962
1963        let s = out.string();
1964        assert!(
1965            !s.contains("Instances:"),
1966            "Instances line must be absent when count is None; got:\n{s}"
1967        );
1968    }
1969
1970    #[test]
1971    fn resource_type_describe_prose_no_label() {
1972        let out = SharedBuf::new();
1973        let mut renderer = ProseRenderer::with_writer(out.clone());
1974        let mut detail = make_manuscript_detail();
1975        detail.label = None;
1976        let meta = make_meta("anonymous", "api.dasch.swiss");
1977        renderer.resource_type_describe(&detail, &meta).unwrap();
1978
1979        let s = out.string();
1980        // Label line must be absent
1981        assert!(
1982            !s.contains("Label:"),
1983            "Label: line must be absent when label is None; got:\n{s}"
1984        );
1985        // Header still present
1986        assert!(
1987            s.contains("Resource-type: manuscript"),
1988            "header missing; got:\n{s}"
1989        );
1990        // "None" must not appear
1991        assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1992    }
1993
1994    #[test]
1995    fn resource_type_describe_prose_zero_fields() {
1996        let out = SharedBuf::new();
1997        let mut renderer = ProseRenderer::with_writer(out.clone());
1998        let detail = ResourceTypeDetail {
1999            name: "Empty".into(),
2000            iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#Empty".into(),
2001            label: None,
2002            data_model: "minimal".into(),
2003            representation: None,
2004            super_types: vec![],
2005            fields: vec![],
2006            count: None,
2007        };
2008        let meta = make_meta("anonymous", "api.dasch.swiss");
2009        renderer.resource_type_describe(&detail, &meta).unwrap();
2010
2011        let s = out.string();
2012        // Zero branch: "Fields (0)" without colon, no rows
2013        assert!(
2014            s.contains("  Fields (0)"),
2015            "zero fields branch missing; got:\n{s}"
2016        );
2017        // Must NOT have a colon after "(0)"
2018        let fields_line = s
2019            .lines()
2020            .find(|l| l.contains("Fields (0)"))
2021            .expect("must have Fields line");
2022        assert!(
2023            !fields_line.contains("Fields (0):"),
2024            "zero branch must NOT have a colon; got: {fields_line:?}"
2025        );
2026        // No Extends or Representation lines
2027        assert!(
2028            !s.contains("Extends:"),
2029            "Extends line must be absent; got:\n{s}"
2030        );
2031        assert!(
2032            !s.contains("Representation:"),
2033            "Representation line must be absent; got:\n{s}"
2034        );
2035    }
2036
2037    #[test]
2038    fn resource_type_describe_prose_no_representation() {
2039        let out = SharedBuf::new();
2040        let mut renderer = ProseRenderer::with_writer(out.clone());
2041        let mut detail = make_manuscript_detail();
2042        detail.representation = None;
2043        let meta = make_meta("anonymous", "api.dasch.swiss");
2044        renderer.resource_type_describe(&detail, &meta).unwrap();
2045
2046        let s = out.string();
2047        // Representation line must be absent
2048        assert!(
2049            !s.contains("Representation:"),
2050            "Representation: line must be absent when representation is None; got:\n{s}"
2051        );
2052    }
2053
2054    #[test]
2055    fn resource_type_describe_prose_include_builtins_marker() {
2056        // A field with is_builtin=true must get a trailing " (built-in)" marker.
2057        let out = SharedBuf::new();
2058        let mut renderer = ProseRenderer::with_writer(out.clone());
2059        let detail = ResourceTypeDetail {
2060            name: "thing".into(),
2061            iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#thing".into(),
2062            label: None,
2063            data_model: "minimal".into(),
2064            representation: None,
2065            super_types: vec![],
2066            fields: vec![
2067                Field {
2068                    name: "hasText".into(),
2069                    iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#hasText".into(),
2070                    label: Some("Text content".into()),
2071                    value_type: ValueType::Text,
2072                    link_target: None,
2073                    cardinality: Cardinality::One,
2074                    is_builtin: false,
2075                    data_model: Some("minimal".into()),
2076                },
2077                Field {
2078                    name: "arkUrl".into(),
2079                    iri: "http://api.knora.org/ontology/knora-api/v2#arkUrl".into(),
2080                    label: Some("ARK URL".into()),
2081                    value_type: ValueType::Uri,
2082                    link_target: None,
2083                    cardinality: Cardinality::One,
2084                    is_builtin: true,
2085                    data_model: None,
2086                },
2087            ],
2088            count: None,
2089        };
2090        let meta = make_meta("anonymous", "api.dasch.swiss");
2091        renderer.resource_type_describe(&detail, &meta).unwrap();
2092
2093        let s = out.string();
2094        // Project field must NOT have (built-in) marker
2095        let text_line = s.lines().find(|l| l.contains("hasText")).unwrap();
2096        assert!(
2097            !text_line.contains("(built-in)"),
2098            "project field must not have (built-in) marker; got: {text_line:?}"
2099        );
2100        // Built-in field must have (built-in) marker
2101        let ark_line = s.lines().find(|l| l.contains("arkUrl")).unwrap();
2102        assert!(
2103            ark_line.contains("(built-in)"),
2104            "built-in field must have (built-in) marker; got: {ark_line:?}"
2105        );
2106    }
2107
2108    #[test]
2109    fn resource_type_describe_prose_extends_multiple_supers() {
2110        // Multiple super_types → joined with ", "
2111        let out = SharedBuf::new();
2112        let mut renderer = ProseRenderer::with_writer(out.clone());
2113        let detail = ResourceTypeDetail {
2114            name: "letter".into(),
2115            iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
2116            label: Some("Letter".into()),
2117            data_model: "beol".into(),
2118            representation: None,
2119            super_types: vec!["basicLetter".into(), "writtenSource".into()],
2120            fields: vec![],
2121            count: None,
2122        };
2123        let meta = make_meta("anonymous", "api.dasch.swiss");
2124        renderer.resource_type_describe(&detail, &meta).unwrap();
2125
2126        let s = out.string();
2127        assert!(
2128            s.contains("Extends:        basicLetter, writtenSource"),
2129            "multiple supers must be joined with ', '; got:\n{s}"
2130        );
2131    }
2132
2133    // ── resource_describe prose values tests ─────────────────────────────────
2134
2135    use crate::model::{
2136        DatePoint, DateValue, FieldValues, FileValue, ResourceAccess, ResourceDetail,
2137        ResourceVisibility, Value, ValueContent,
2138    };
2139
2140    fn make_resource_detail_no_values() -> ResourceDetail {
2141        ResourceDetail {
2142            label: "Test Resource".into(),
2143            iri: "http://rdfh.ch/0803/abc123".into(),
2144            resource_type: "Page".into(),
2145            ark_url: Some("ark:/72163/1/0803/abc123".into()),
2146            creation_date: Some("2021-01-01T00:00:00Z".into()),
2147            last_modified: None,
2148            attached_project: Some("http://rdfh.ch/projects/0803".into()),
2149            owner: Some("http://rdfh.ch/users/alice".into()),
2150            visibility: Some(ResourceVisibility::Public),
2151            your_access: Some(ResourceAccess::View),
2152            values: None,
2153        }
2154    }
2155
2156    #[test]
2157    fn resource_describe_prose_no_values_unchanged() {
2158        // When values is None, output must be byte-identical to 8b behaviour —
2159        // no "Values:" section.
2160        let out = SharedBuf::new();
2161        let mut renderer = ProseRenderer::with_writer(out.clone());
2162        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2163        renderer
2164            .resource_describe(&make_resource_detail_no_values(), &meta)
2165            .unwrap();
2166
2167        let s = out.string();
2168        assert!(s.contains("Resource: Test Resource"), "header missing");
2169        assert!(
2170            !s.contains("Values:"),
2171            "Values: section must be absent when values is None"
2172        );
2173    }
2174
2175    #[test]
2176    fn resource_describe_prose_some_empty_values() {
2177        // When values is Some(vec![]), render "Values: (none)"
2178        let out = SharedBuf::new();
2179        let mut renderer = ProseRenderer::with_writer(out.clone());
2180        let mut detail = make_resource_detail_no_values();
2181        detail.values = Some(vec![]);
2182        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2183        renderer.resource_describe(&detail, &meta).unwrap();
2184
2185        let s = out.string();
2186        assert!(
2187            s.contains("Values: (none)"),
2188            "empty values must render as 'Values: (none)'; got:\n{s}"
2189        );
2190    }
2191
2192    #[test]
2193    fn resource_describe_prose_values_section() {
2194        // Smoke test: verify Values: section is present and basic field rendering works.
2195        let out = SharedBuf::new();
2196        let mut renderer = ProseRenderer::with_writer(out.clone());
2197        let mut detail = make_resource_detail_no_values();
2198        detail.values = Some(vec![
2199            FieldValues {
2200                name: "hasTitle".into(),
2201                label: Some("Title".into()),
2202                values: vec![ValueContent::Text("Incunabula Page".into()).into()],
2203            },
2204            FieldValues {
2205                name: "seqnum".into(),
2206                label: None, // degraded label — name only, no parens
2207                values: vec![ValueContent::Integer(42).into()],
2208            },
2209        ]);
2210        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2211        renderer.resource_describe(&detail, &meta).unwrap();
2212
2213        let s = out.string();
2214        assert!(s.contains("Values:"), "Values: header missing; got:\n{s}");
2215        // Field with label: "<label> (<name>)"
2216        assert!(
2217            s.contains("Title (hasTitle)"),
2218            "labelled field header format wrong; got:\n{s}"
2219        );
2220        assert!(
2221            s.contains("Incunabula Page"),
2222            "text value missing; got:\n{s}"
2223        );
2224        // Field without label: "<name>" only, no parentheses
2225        let seqnum_line = s.lines().find(|l| l.contains("seqnum")).unwrap();
2226        assert!(
2227            !seqnum_line.contains('('),
2228            "unlabelled field must not have parentheses; got: {seqnum_line:?}"
2229        );
2230        assert!(s.contains("42"), "integer value missing; got:\n{s}");
2231    }
2232
2233    #[test]
2234    fn resource_describe_prose_value_with_comment() {
2235        // A value carrying a `knora-api:valueHasComment` renders an indented
2236        // "comment: <text>" line directly under the value line.
2237        let out = SharedBuf::new();
2238        let mut renderer = ProseRenderer::with_writer(out.clone());
2239        let mut detail = make_resource_detail_no_values();
2240        detail.values = Some(vec![FieldValues {
2241            name: "hasTranscription".into(),
2242            label: Some("Transcription".into()),
2243            values: vec![Value {
2244                content: ValueContent::Text("some transcription".into()),
2245                comment: Some("reading uncertain".into()),
2246            }],
2247        }]);
2248        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2249        renderer.resource_describe(&detail, &meta).unwrap();
2250
2251        let s = out.string();
2252        assert!(
2253            s.contains("some transcription"),
2254            "value line missing; got:\n{s}"
2255        );
2256        assert!(
2257            s.contains("      comment: reading uncertain"),
2258            "comment line missing or mis-indented; got:\n{s}"
2259        );
2260        // Comment line must immediately follow the value line.
2261        let lines: Vec<&str> = s.lines().collect();
2262        let value_idx = lines
2263            .iter()
2264            .position(|l| l.contains("some transcription"))
2265            .unwrap();
2266        assert_eq!(
2267            lines[value_idx + 1],
2268            "      comment: reading uncertain",
2269            "comment line must directly follow the value line; got:\n{s}"
2270        );
2271    }
2272
2273    #[test]
2274    fn resource_describe_prose_value_without_comment_renders_no_comment_line() {
2275        // A value with `comment: None` must NOT emit a "comment: " line at all
2276        // (not an empty one) — the companion negative case to
2277        // `resource_describe_prose_value_with_comment`.
2278        let out = SharedBuf::new();
2279        let mut renderer = ProseRenderer::with_writer(out.clone());
2280        let mut detail = make_resource_detail_no_values();
2281        detail.values = Some(vec![FieldValues {
2282            name: "hasTranscription".into(),
2283            label: Some("Transcription".into()),
2284            values: vec![ValueContent::Text("some transcription".into()).into()],
2285        }]);
2286        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2287        renderer.resource_describe(&detail, &meta).unwrap();
2288
2289        let s = out.string();
2290        assert!(
2291            s.contains("some transcription"),
2292            "value line missing; got:\n{s}"
2293        );
2294        assert!(
2295            !s.contains("comment:"),
2296            "no comment line must be emitted when value.comment is None; got:\n{s}"
2297        );
2298    }
2299
2300    #[test]
2301    fn resource_describe_prose_link_with_label() {
2302        let out = SharedBuf::new();
2303        let mut renderer = ProseRenderer::with_writer(out.clone());
2304        let mut detail = make_resource_detail_no_values();
2305        detail.values = Some(vec![FieldValues {
2306            name: "isPartOf".into(),
2307            label: Some("Is part of".into()),
2308            values: vec![
2309                ValueContent::Link {
2310                    target_iri: "http://rdfh.ch/0803/book1".into(),
2311                    target_label: Some("Incunabula Book".into()),
2312                }
2313                .into(),
2314                ValueContent::Link {
2315                    target_iri: "http://rdfh.ch/0803/book2".into(),
2316                    target_label: None, // degraded link — no brackets
2317                }
2318                .into(),
2319            ],
2320        }]);
2321        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2322        renderer.resource_describe(&detail, &meta).unwrap();
2323
2324        let s = out.string();
2325        // Link with label: "→ <label> [<iri>]"
2326        assert!(
2327            s.contains("\u{2192} Incunabula Book [http://rdfh.ch/0803/book1]"),
2328            "link with label rendered incorrectly; got:\n{s}"
2329        );
2330        // Link without label: "→ <iri>" (no brackets)
2331        assert!(
2332            s.contains("\u{2192} http://rdfh.ch/0803/book2"),
2333            "degraded link (no label) rendered incorrectly; got:\n{s}"
2334        );
2335        // No square brackets around the degraded link
2336        let degraded_line = s
2337            .lines()
2338            .find(|l| l.contains("http://rdfh.ch/0803/book2"))
2339            .unwrap();
2340        assert!(
2341            !degraded_line.contains('['),
2342            "degraded link must not have brackets; got: {degraded_line:?}"
2343        );
2344    }
2345
2346    #[test]
2347    fn resource_describe_prose_file_still_image_with_dims() {
2348        let out = SharedBuf::new();
2349        let mut renderer = ProseRenderer::with_writer(out.clone());
2350        let mut detail = make_resource_detail_no_values();
2351        detail.values = Some(vec![FieldValues {
2352            name: "hasStillImageFileValue".into(),
2353            label: None,
2354            values: vec![
2355                ValueContent::File(FileValue {
2356                    value_type: ValueType::StillImage,
2357                    filename: "image.jp2".into(),
2358                    url: "https://iiif.example.com/image.jp2/full/max/0/default.jpg".into(),
2359                    width: Some(1200),
2360                    height: Some(800),
2361                })
2362                .into(),
2363            ],
2364        }]);
2365        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2366        renderer.resource_describe(&detail, &meta).unwrap();
2367
2368        let s = out.string();
2369        // Still-image: "<filename> (<W>×<H>) <url>"
2370        assert!(
2371            s.contains("image.jp2 (1200\u{d7}800) https://iiif.example.com"),
2372            "still-image with dims rendered incorrectly; got:\n{s}"
2373        );
2374    }
2375
2376    #[test]
2377    fn resource_describe_prose_date_rendering() {
2378        let out = SharedBuf::new();
2379        let mut renderer = ProseRenderer::with_writer(out.clone());
2380        let mut detail = make_resource_detail_no_values();
2381        // Single-point date (start == end)
2382        let pt = DatePoint {
2383            year: Some(1489),
2384            month: None,
2385            day: None,
2386            era: Some("CE".into()),
2387        };
2388        let single = DateValue {
2389            calendar: "GREGORIAN".into(),
2390            start: pt.clone(),
2391            end: pt.clone(),
2392        };
2393        // Range date
2394        let range = DateValue {
2395            calendar: "GREGORIAN".into(),
2396            start: DatePoint {
2397                year: Some(1489),
2398                month: None,
2399                day: None,
2400                era: Some("CE".into()),
2401            },
2402            end: DatePoint {
2403                year: Some(1490),
2404                month: None,
2405                day: None,
2406                era: Some("CE".into()),
2407            },
2408        };
2409        detail.values = Some(vec![FieldValues {
2410            name: "hasDate".into(),
2411            label: Some("Date".into()),
2412            values: vec![
2413                ValueContent::Date(single).into(),
2414                ValueContent::Date(range).into(),
2415            ],
2416        }]);
2417        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2418        renderer.resource_describe(&detail, &meta).unwrap();
2419
2420        let s = out.string();
2421        // Single-point date
2422        assert!(
2423            s.contains("1489 CE (GREGORIAN)"),
2424            "single-point date rendered incorrectly; got:\n{s}"
2425        );
2426        // Range date with en-dash
2427        assert!(
2428            s.contains("1489 CE \u{2013} 1490 CE (GREGORIAN)"),
2429            "range date rendered incorrectly; got:\n{s}"
2430        );
2431    }
2432
2433    // ── resource label control-char sanitisation (Phase 8.5 #1) ───────────────
2434
2435    #[test]
2436    fn resource_describe_prose_strips_control_chars_in_label() {
2437        // A resource (instance) label carrying control characters must not reach
2438        // the terminal raw — terminal-injection / display hardening.
2439        let out = SharedBuf::new();
2440        let mut renderer = ProseRenderer::with_writer(out.clone());
2441        let mut detail = make_resource_detail_no_values();
2442        detail.label = "Bad\u{1b}[31mLabel\u{7f}".into();
2443        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2444        renderer.resource_describe(&detail, &meta).unwrap();
2445
2446        let s = out.string();
2447        assert!(
2448            s.contains("Resource: Bad[31mLabel"),
2449            "label control chars not stripped; got:\n{s}"
2450        );
2451        assert!(
2452            !s.contains('\u{1b}') && !s.contains('\u{7f}'),
2453            "control characters leaked into prose output; got:\n{s:?}"
2454        );
2455    }
2456
2457    #[test]
2458    fn resources_prose_strips_control_chars_in_label() {
2459        use crate::model::ResourceSummary;
2460
2461        let out = SharedBuf::new();
2462        let mut renderer = ProseRenderer::with_writer(out.clone());
2463        let view = ResourceListView {
2464            items: vec![ResourceSummary {
2465                label: "Bad\u{1b}[31mLabel\u{7f}".into(),
2466                iri: "http://rdfh.ch/0803/abc123".into(),
2467                ark_url: None,
2468                creation_date: None,
2469                last_modified: None,
2470                resource_type: "Page".into(),
2471            }],
2472            total: 1,
2473            filter: None,
2474            resource_type: "Page".into(),
2475            pagination: ResourceListPagination::SinglePage {
2476                page: 0,
2477                may_have_more: false,
2478            },
2479        };
2480        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2481        renderer.resources(&view, &meta).unwrap();
2482
2483        let s = out.string();
2484        assert!(
2485            s.contains("Bad[31mLabel"),
2486            "label control chars not stripped; got:\n{s}"
2487        );
2488        assert!(
2489            !s.contains('\u{1b}') && !s.contains('\u{7f}'),
2490            "control characters leaked into prose output; got:\n{s:?}"
2491        );
2492    }
2493}