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