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