Skip to main content

dsp_cli/render/
json.rs

1//! JSON renderer — newline-delimited JSON output.
2//!
3//! Every response is a single object with `_meta` first, plus exactly one of
4//! `data` (success) or `error` (failure):
5//!
6//! ```json
7//! {"_meta": {"server": "…", "auth": "…", "exit_code": 0}, "data": { … }}
8//! {"_meta": {"server": "…", "auth": "…", "exit_code": 3}, "error": {"kind": "…", "message": "…"}}
9//! ```
10//!
11//! `data` is an object for single-result commands and an array for list
12//! commands (Phase 4+). The shape is uniform across every command so a
13//! consumer always parses one object from stdout: if `.error` is present it
14//! failed, otherwise read `.data`. The `server` lives only in `_meta` — it is
15//! not repeated inside `data`. Key order is deterministic (serde_json
16//! `preserve_order`); `_meta` is always first. See dsp-cli/ADR-0003 and dsp-cli/ADR-0012.
17
18use std::io::{self, Write};
19
20use serde_json::json;
21
22use crate::diagnostic::Diagnostic;
23use crate::model::{
24    DataModelDetail, DataModelStructure, DatePoint, ProjectDetail, ResourceDetail, ValueContent, VocabularyDetail,
25};
26use crate::render::auth::{AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome};
27use crate::render::dump::{DumpDeleteOutcome, DumpOutcome};
28use crate::render::vocabulary::{NestedVocabularyNode, nest_vocabulary_detail};
29use crate::render::{
30    DataModelListView, MetaContext, ProjectListView, Renderer, ResourceListPagination, ResourceListView,
31    ResourceTypeListView, VocabularyListView,
32};
33
34/// Renders output as newline-delimited JSON.
35pub struct JsonRenderer {
36    out: Box<dyn Write>,
37}
38
39impl JsonRenderer {
40    /// Creates a renderer writing to stdout.
41    ///
42    /// Stdout is wrapped in `BrokenPipeWriter` so `dsp ... | head` exits 0
43    /// silently instead of surfacing a broken pipe as `Diagnostic::Internal`.
44    pub fn new() -> Self {
45        Self {
46            out: Box::new(crate::util::BrokenPipeWriter::new(io::stdout())),
47        }
48    }
49
50    /// Creates a renderer writing to an arbitrary `Write` sink (used in tests).
51    pub fn with_writer(w: impl Write + 'static) -> Self {
52        Self { out: Box::new(w) }
53    }
54}
55
56impl Default for JsonRenderer {
57    fn default() -> Self {
58        Self::new()
59    }
60}
61
62/// Build the `_meta` block common to every JSON output object.
63///
64/// `server`/`auth` are included only when their source string is non-empty;
65/// `exit_code` is always included. On every existing success-path call site,
66/// `server_label`/`auth_state` are always non-empty (set by `Config::resolve`
67/// and `read_auth_state` respectively), so this omission only fires for the
68/// top-level error path (plan 032 D3), which has no server/auth context yet.
69fn meta_block(meta: &MetaContext, exit_code: u8) -> serde_json::Value {
70    use serde_json::Map;
71    let mut m = Map::new();
72    if !meta.server_label.is_empty() {
73        m.insert("server".into(), serde_json::Value::String(meta.server_label.clone()));
74    }
75    if !meta.auth_state.is_empty() {
76        m.insert("auth".into(), serde_json::Value::String(meta.auth_state.clone()));
77    }
78    m.insert("exit_code".into(), serde_json::Value::from(exit_code));
79    serde_json::Value::Object(m)
80}
81
82/// Build a verbatim, lossless `[{"value": …, "language": …}]` array from a
83/// `LocalizedText` slice (plan 034). json is the lossless path for vocabulary
84/// labels/comments — no per-language column collapsing (that is tabular-only,
85/// see `src/render/vocabulary.rs`). `language: None` serialises as JSON
86/// `null`, matching `project_describe`'s `ProjectDescription` array precedent.
87fn localized_text_array(items: &[crate::model::LocalizedText]) -> Vec<serde_json::Value> {
88    items
89        .iter()
90        .map(|l| {
91            json!({
92                "value": l.value,
93                "language": l.language,
94            })
95        })
96        .collect()
97}
98
99/// Build a JSON value object for a single `ValueContent` (per dsp-cli/ADR-0013 matrix).
100///
101/// Key order is deterministic: `value_type` is always first, then type-specific
102/// keys in the order specified by the matrix. Raw server values are kept verbatim
103/// (no sanitisation — dsp-cli/ADR-0003 fidelity; sanitisation is prose-only per D7).
104fn value_content_to_json(vc: &ValueContent) -> serde_json::Value {
105    use serde_json::Map;
106    let mut m = Map::new();
107    match vc {
108        ValueContent::Text(s) => {
109            m.insert("value_type".into(), serde_json::Value::String("text".into()));
110            m.insert("text".into(), serde_json::Value::String(s.clone()));
111        }
112        ValueContent::Integer(n) => {
113            m.insert("value_type".into(), serde_json::Value::String("integer".into()));
114            m.insert("value".into(), serde_json::Value::Number((*n).into()));
115        }
116        ValueContent::Decimal(s) => {
117            m.insert("value_type".into(), serde_json::Value::String("decimal".into()));
118            m.insert("value".into(), serde_json::Value::String(s.clone()));
119        }
120        ValueContent::Boolean(b) => {
121            m.insert("value_type".into(), serde_json::Value::String("boolean".into()));
122            m.insert("value".into(), serde_json::Value::Bool(*b));
123        }
124        ValueContent::Date(dv) => {
125            m.insert("value_type".into(), serde_json::Value::String("date".into()));
126            m.insert("calendar".into(), serde_json::Value::String(dv.calendar.clone()));
127            // Build start/end point objects — omit absent sub-fields (null for era when None).
128            let point_to_json = |p: &DatePoint| {
129                json!({
130                    "year": p.year,
131                    "month": p.month,
132                    "day": p.day,
133                    "era": p.era,
134                })
135            };
136            m.insert("start".into(), point_to_json(&dv.start));
137            m.insert("end".into(), point_to_json(&dv.end));
138        }
139        ValueContent::Time(s) => {
140            m.insert("value_type".into(), serde_json::Value::String("time".into()));
141            m.insert("value".into(), serde_json::Value::String(s.clone()));
142        }
143        ValueContent::Uri(s) => {
144            m.insert("value_type".into(), serde_json::Value::String("uri".into()));
145            m.insert("value".into(), serde_json::Value::String(s.clone()));
146        }
147        ValueContent::Color(s) => {
148            m.insert("value_type".into(), serde_json::Value::String("color".into()));
149            m.insert("value".into(), serde_json::Value::String(s.clone()));
150        }
151        ValueContent::Geoname(s) => {
152            m.insert("value_type".into(), serde_json::Value::String("geoname".into()));
153            m.insert("value".into(), serde_json::Value::String(s.clone()));
154        }
155        ValueContent::VocabularyItem { node_iri, label } => {
156            m.insert("value_type".into(), serde_json::Value::String("vocabulary-item".into()));
157            m.insert("node_iri".into(), serde_json::Value::String(node_iri.clone()));
158            let label_val = match label {
159                Some(s) => serde_json::Value::String(s.clone()),
160                None => serde_json::Value::Null,
161            };
162            m.insert("label".into(), label_val);
163        }
164        ValueContent::Link { target_iri, target_label } => {
165            m.insert("value_type".into(), serde_json::Value::String("link".into()));
166            m.insert("target_iri".into(), serde_json::Value::String(target_iri.clone()));
167            let tl_val = match target_label {
168                Some(s) => serde_json::Value::String(s.clone()),
169                None => serde_json::Value::Null,
170            };
171            m.insert("target_label".into(), tl_val);
172        }
173        ValueContent::File(fv) => {
174            use crate::model::resource_type::ValueType;
175            let type_token = fv.value_type.as_token().to_string();
176            m.insert("value_type".into(), serde_json::Value::String(type_token));
177            m.insert("filename".into(), serde_json::Value::String(fv.filename.clone()));
178            m.insert("url".into(), serde_json::Value::String(fv.url.clone()));
179            // width/height: only meaningful for still-image, null for others.
180            match fv.value_type {
181                ValueType::StillImage => {
182                    let w_val: serde_json::Value = fv
183                        .width
184                        .map_or(serde_json::Value::Null, |w| serde_json::Value::Number(w.into()));
185                    let h_val: serde_json::Value = fv
186                        .height
187                        .map_or(serde_json::Value::Null, |h| serde_json::Value::Number(h.into()));
188                    m.insert("width".into(), w_val);
189                    m.insert("height".into(), h_val);
190                }
191                _ => {
192                    m.insert("width".into(), serde_json::Value::Null);
193                    m.insert("height".into(), serde_json::Value::Null);
194                }
195            }
196        }
197        ValueContent::Raw { value_type, text } => {
198            m.insert("value_type".into(), serde_json::Value::String(value_type.clone()));
199            m.insert("text".into(), serde_json::Value::String(text.clone()));
200        }
201    }
202    serde_json::Value::Object(m)
203}
204
205/// Map a `Diagnostic` variant to its stable JSON `kind` string (per dsp-cli/ADR-0012).
206fn diagnostic_kind(diag: &Diagnostic) -> &'static str {
207    match diag {
208        Diagnostic::Usage(_) => "usage",
209        Diagnostic::AuthRequired(_) => "auth_required",
210        Diagnostic::NotFound(_) => "not_found",
211        Diagnostic::ServerError(_) => "server_error",
212        Diagnostic::Network(_) => "network",
213        Diagnostic::Conflict(_) => "conflict",
214        Diagnostic::Io(_) => "io",
215        Diagnostic::Internal(_) | Diagnostic::NotImplemented(_) => "internal",
216    }
217}
218
219impl Renderer for JsonRenderer {
220    fn diagnostic(&mut self, diag: &Diagnostic, meta: &MetaContext) -> Result<(), Diagnostic> {
221        // JSON errors emit the full dsp-cli/ADR-0012 error envelope to stdout so a JSON
222        // consumer has a single stream to parse (not stdout + stderr).
223        let exit_code = diag.exit_category() as u8;
224        let obj = json!({
225            "_meta": meta_block(meta, exit_code),
226            "error": {
227                "kind": diagnostic_kind(diag),
228                "message": diag.to_string(),
229            },
230        });
231        writeln!(
232            self.out,
233            "{}",
234            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
235        )?;
236        Ok(())
237    }
238
239    fn auth_login(&mut self, outcome: &AuthLoginOutcome, meta: &MetaContext) -> Result<(), Diagnostic> {
240        let obj = json!({
241            "_meta": meta_block(meta, 0),
242            "data": {
243                "user": outcome.user,
244                "expires_at": outcome.expires_at.map(|dt| dt.to_rfc3339()),
245                "state": "login_success",
246            },
247        });
248        writeln!(
249            self.out,
250            "{}",
251            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
252        )?;
253        Ok(())
254    }
255
256    fn auth_status(&mut self, outcome: &AuthStatusOutcome, meta: &MetaContext) -> Result<(), Diagnostic> {
257        let obj = match outcome {
258            AuthStatusOutcome::LoggedIn { server: _, user, expires_at, expired } => json!({
259                "_meta": meta_block(meta, 0),
260                "data": {
261                    "user": user,
262                    "expires_at": expires_at.map(|dt| dt.to_rfc3339()),
263                    "state": if *expired { "expired" } else { "logged_in" },
264                },
265            }),
266            // DSP_TOKEN env-override: data shape is uniform with the LoggedIn case
267            // (user: null, expires_at: rfc3339 or null, state: "logged_in"|"expired").
268            // The "via DSP_TOKEN" disclosure is carried by _meta.auth, not by a
269            // source key in data, to keep the data shape stable across all three outcomes.
270            AuthStatusOutcome::AuthenticatedViaEnv { server: _, expires_at, expired } => json!({
271                "_meta": meta_block(meta, 0),
272                "data": {
273                    "user": null,
274                    "expires_at": expires_at.map(|dt| dt.to_rfc3339()),
275                    "state": if *expired { "expired" } else { "logged_in" },
276                },
277            }),
278            AuthStatusOutcome::NotLoggedIn { server: _ } => json!({
279                "_meta": meta_block(meta, 0),
280                "data": {
281                    "user": null,
282                    "expires_at": null,
283                    "state": "not_logged_in",
284                },
285            }),
286        };
287        writeln!(
288            self.out,
289            "{}",
290            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
291        )?;
292        Ok(())
293    }
294
295    fn auth_logout(&mut self, outcome: &AuthLogoutOutcome, meta: &MetaContext) -> Result<(), Diagnostic> {
296        let obj = json!({
297            "_meta": meta_block(meta, 0),
298            "data": {
299                "was_cached": outcome.was_cached,
300                "state": if outcome.was_cached { "logout_was_cached" } else { "logout_no_op" },
301            },
302        });
303        writeln!(
304            self.out,
305            "{}",
306            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
307        )?;
308        Ok(())
309    }
310
311    fn auth_set_token(&mut self, outcome: &AuthSetTokenOutcome, meta: &MetaContext) -> Result<(), Diagnostic> {
312        let obj = json!({
313            "_meta": meta_block(meta, 0),
314            "data": {
315                "user": outcome.user,
316                "expires_at": outcome.expires_at.map(|dt| dt.to_rfc3339()),
317                "state": "token_cached",
318            },
319        });
320        writeln!(
321            self.out,
322            "{}",
323            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
324        )?;
325        Ok(())
326    }
327
328    fn project_dump(&mut self, outcome: &DumpOutcome, meta: &MetaContext) -> Result<(), Diagnostic> {
329        let obj = json!({
330            "_meta": meta_block(meta, 0),
331            "data": {
332                "path": outcome.path.display().to_string(),
333                "bytes": outcome.bytes,
334                "cleaned_up": outcome.cleaned_up,
335                "reused": outcome.reused,
336                "created_at": outcome.created_at.map(|dt| dt.to_rfc3339()),
337            },
338        });
339        writeln!(
340            self.out,
341            "{}",
342            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
343        )?;
344        Ok(())
345    }
346
347    fn project_dump_deleted(&mut self, outcome: &DumpDeleteOutcome, meta: &MetaContext) -> Result<(), Diagnostic> {
348        let obj = if let Some(ref note) = outcome.note {
349            json!({
350                "_meta": meta_block(meta, 0),
351                "data": {
352                    "deleted": outcome.deleted,
353                    "note": note,
354                },
355            })
356        } else {
357            json!({
358                "_meta": meta_block(meta, 0),
359                "data": {
360                    "deleted": outcome.deleted,
361                },
362            })
363        };
364        writeln!(
365            self.out,
366            "{}",
367            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
368        )?;
369        Ok(())
370    }
371
372    fn projects(&mut self, view: &ProjectListView, meta: &MetaContext) -> Result<(), Diagnostic> {
373        // Build data array. Each element uses `json!` with insertion-order keys
374        // (preserve_order feature on serde_json, per dsp-cli/ADR-0003).
375        // longname None → JSON null.
376        let data: Vec<serde_json::Value> = view
377            .items
378            .iter()
379            .map(|item| {
380                json!({
381                    "iri": item.iri,
382                    "shortcode": item.shortcode,
383                    "shortname": item.shortname,
384                    "longname": item.longname,
385                    "data_models": item.data_models,
386                })
387            })
388            .collect();
389
390        let obj = json!({
391            "_meta": meta_block(meta, 0),
392            "data": data,
393        });
394        writeln!(
395            self.out,
396            "{}",
397            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
398        )?;
399        Ok(())
400    }
401
402    fn project_describe(&mut self, project: &ProjectDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
403        // `data` is a single object (dsp-cli/ADR-0003). Deterministic key order via `json!`
404        // (preserve_order feature ensures insertion order).
405        let description: Vec<serde_json::Value> = project
406            .description
407            .iter()
408            .map(|d| {
409                json!({
410                    "value": d.value,
411                    "language": d.language,
412                })
413            })
414            .collect();
415
416        let data_models: Vec<serde_json::Value> = project
417            .data_models
418            .iter()
419            .map(|dm| {
420                json!({
421                    "name": dm.name,
422                    "iri": dm.iri,
423                })
424            })
425            .collect();
426
427        let obj = json!({
428            "_meta": meta_block(meta, 0),
429            "data": {
430                "iri": project.iri,
431                "shortcode": project.shortcode,
432                "shortname": project.shortname,
433                "longname": project.longname,
434                "description": description,
435                "keywords": project.keywords,
436                "data_models": data_models,
437            },
438        });
439        writeln!(
440            self.out,
441            "{}",
442            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
443        )?;
444        Ok(())
445    }
446
447    fn data_model_describe(&mut self, detail: &DataModelDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
448        // dsp-cli/ADR-0003 single-object envelope. `last_modified` is the full RFC3339
449        // string (lossless). `resource_types` is an array of per-resource-type
450        // objects (name, iri, label).
451        let resource_types: Vec<serde_json::Value> = detail
452            .resource_types
453            .iter()
454            .map(|rt| {
455                json!({
456                    "name": rt.name,
457                    "iri": rt.iri,
458                    "label": rt.label,
459                })
460            })
461            .collect();
462
463        let obj = json!({
464            "_meta": meta_block(meta, 0),
465            "data": {
466                "name": detail.name,
467                "iri": detail.iri,
468                "label": detail.label,
469                "last_modified": detail.last_modified,
470                "resource_types": resource_types,
471            },
472        });
473        writeln!(
474            self.out,
475            "{}",
476            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
477        )?;
478        Ok(())
479    }
480
481    fn data_models(&mut self, view: &DataModelListView, meta: &MetaContext) -> Result<(), Diagnostic> {
482        // Build data array — per-item key order via `json!` insertion order
483        // (preserve_order feature on serde_json, per dsp-cli/ADR-0003).
484        // label None → JSON null; last_modified None → JSON null; is_builtin → bool.
485        let data: Vec<serde_json::Value> = view
486            .items
487            .iter()
488            .map(|item| {
489                json!({
490                    "name": item.name,
491                    "iri": item.iri,
492                    "label": item.label,
493                    "last_modified": item.last_modified,
494                    "is_builtin": item.is_builtin,
495                })
496            })
497            .collect();
498
499        let obj = json!({
500            "_meta": meta_block(meta, 0),
501            "data": data,
502        });
503        writeln!(
504            self.out,
505            "{}",
506            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
507        )?;
508        Ok(())
509    }
510
511    fn resource_types(&mut self, view: &ResourceTypeListView, meta: &MetaContext) -> Result<(), Diagnostic> {
512        // Build data array — per-item key order via `json!` insertion order
513        // (preserve_order feature on serde_json, per dsp-cli/ADR-0003).
514        // label None → JSON null; is_builtin → bool.
515        //
516        // `count` (plan 030) is DELIBERATELY omitted (not emitted as `null`)
517        // when the item carries no count — unlike `label`'s always-present
518        // null, this keeps `--count`-less output (the only case exercised by
519        // today's fixtures/snapshots, since no caller sets `count` yet) byte-
520        // identical to pre-030 output. See design plan 030-resource-type-count in the
521        // dsp-incubator archive.
522        let data: Vec<serde_json::Value> = view
523            .items
524            .iter()
525            .map(|item| {
526                let mut obj = json!({
527                    "name": item.name,
528                    "iri": item.iri,
529                    "label": item.label,
530                    "is_builtin": item.is_builtin,
531                });
532                if let Some(count) = item.count {
533                    obj["count"] = serde_json::Value::from(count);
534                }
535                obj
536            })
537            .collect();
538
539        // D3-style: add `note` to _meta when count_caveat is Some (plan 030).
540        let mut meta_obj = meta_block(meta, 0);
541        if let Some(ref cc) = meta.count_caveat {
542            meta_obj["note"] = serde_json::Value::from(cc.as_str());
543        }
544
545        let obj = json!({
546            "_meta": meta_obj,
547            "data": data,
548        });
549        writeln!(
550            self.out,
551            "{}",
552            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
553        )?;
554        Ok(())
555    }
556
557    fn resource_type_describe(
558        &mut self,
559        detail: &crate::model::ResourceTypeDetail,
560        meta: &MetaContext,
561    ) -> Result<(), Diagnostic> {
562        // dsp-cli/ADR-0003 single-object envelope. `_meta` first, `data` is the resource-type
563        // object. Fields array carries one object per field (name, iri, label,
564        // value_type, link_target, cardinality, is_builtin, data_model).
565        let fields: Vec<serde_json::Value> = detail
566            .fields
567            .iter()
568            .map(|f| {
569                json!({
570                    "name": f.name,
571                    "iri": f.iri,
572                    "label": f.label,
573                    "value_type": f.value_type.to_string(),
574                    "link_target": f.link_target,
575                    "cardinality": f.cardinality.to_string(),
576                    "is_builtin": f.is_builtin,
577                    "data_model": f.data_model,
578                })
579            })
580            .collect();
581
582        // D3-style: add `note` to _meta when count_caveat is Some (plan 030).
583        let mut meta_obj = meta_block(meta, 0);
584        if let Some(ref cc) = meta.count_caveat {
585            meta_obj["note"] = serde_json::Value::from(cc.as_str());
586        }
587
588        // `count` (plan 030) is DELIBERATELY omitted (not emitted as `null`)
589        // when `detail.count` is `None` — keeps `--count`-less output (the
590        // only case exercised by today's fixtures/snapshots) byte-identical
591        // to pre-030 output. See design plan 030-resource-type-count in the dsp-incubator
592        // archive.
593        let mut data = json!({
594            "name": detail.name,
595            "iri": detail.iri,
596            "label": detail.label,
597            "data_model": detail.data_model,
598            "representation": detail.representation.as_ref().map(|r| r.to_string()),
599            "super_types": detail.super_types,
600            "fields": fields,
601        });
602        if let Some(count) = detail.count {
603            data["count"] = serde_json::Value::from(count);
604        }
605
606        let obj = json!({
607            "_meta": meta_obj,
608            "data": data,
609        });
610        writeln!(
611            self.out,
612            "{}",
613            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
614        )?;
615        Ok(())
616    }
617
618    fn data_model_structure(&mut self, structure: &DataModelStructure, meta: &MetaContext) -> Result<(), Diagnostic> {
619        // dsp-cli/ADR-0003 flat-array envelope. Each element carries all 5 keys (none omitted).
620        // Optional values are emitted as JSON null (matching resource_type_describe lines
621        // 476-485 which render None Options as null — never skip_serializing_if).
622        let data: Vec<serde_json::Value> = structure
623            .relations
624            .iter()
625            .map(|r| {
626                json!({
627                    "source": r.source,
628                    "target": r.target,
629                    "kind": r.kind.to_string(),
630                    "field": r.field,
631                    "target_data_model": r.target_data_model,
632                })
633            })
634            .collect();
635
636        let obj = json!({
637            "_meta": meta_block(meta, 0),
638            "data": data,
639        });
640        writeln!(
641            self.out,
642            "{}",
643            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
644        )?;
645        Ok(())
646    }
647
648    fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
649        // Build data array.
650        let data: Vec<serde_json::Value> = view
651            .items
652            .iter()
653            .map(|item| {
654                json!({
655                    "label": item.label,
656                    "iri": item.iri,
657                    "ark_url": item.ark_url,
658                    "creation_date": item.creation_date,
659                    "last_modified": item.last_modified,
660                    "resource_type": item.resource_type,
661                })
662            })
663            .collect();
664
665        // Build _meta pagination keys (D5 — two asymmetric shapes by mode).
666        let mut meta_obj = meta_block(meta, 0);
667        match &view.pagination {
668            ResourceListPagination::SinglePage { page, may_have_more } => {
669                meta_obj["page"] = serde_json::Value::from(*page);
670                meta_obj["may_have_more_results"] = serde_json::Value::from(*may_have_more);
671            }
672            ResourceListPagination::AllPages { pages_fetched } => {
673                meta_obj["pages_fetched"] = serde_json::Value::from(*pages_fetched);
674                // AllPages always exits on may_have_more_results = false (loop invariant).
675                meta_obj["may_have_more_results"] = serde_json::Value::from(false);
676            }
677        }
678
679        // D3: add `note` to _meta when filter_warning is Some.
680        if let Some(ref fw) = meta.filter_warning {
681            meta_obj["note"] = serde_json::Value::from(fw.as_str());
682        }
683
684        let obj = json!({
685            "_meta": meta_obj,
686            "data": data,
687        });
688        writeln!(
689            self.out,
690            "{}",
691            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
692        )?;
693        Ok(())
694    }
695
696    fn resource_describe(&mut self, detail: &ResourceDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
697        // dsp-cli/ADR-0003 single-object envelope. `data` is an object (not array).
698        // Keys in deterministic order; `None` → JSON null.
699        // D3: add `note` to _meta when filter_warning is Some.
700        let mut meta_obj = meta_block(meta, 0);
701        if let Some(ref fw) = meta.filter_warning {
702            meta_obj["note"] = serde_json::Value::from(fw.as_str());
703        }
704
705        // Build data object with explicit key ordering (preserve_order, dsp-cli/ADR-0003).
706        let mut data = serde_json::Map::new();
707        data.insert("label".into(), serde_json::Value::String(detail.label.clone()));
708        data.insert("iri".into(), serde_json::Value::String(detail.iri.clone()));
709        data.insert("resource_type".into(), serde_json::Value::String(detail.resource_type.clone()));
710        data.insert(
711            "ark_url".into(),
712            detail
713                .ark_url
714                .as_ref()
715                .map_or(serde_json::Value::Null, |s| serde_json::Value::String(s.clone())),
716        );
717        data.insert(
718            "creation_date".into(),
719            detail
720                .creation_date
721                .as_ref()
722                .map_or(serde_json::Value::Null, |s| serde_json::Value::String(s.clone())),
723        );
724        data.insert(
725            "last_modified".into(),
726            detail
727                .last_modified
728                .as_ref()
729                .map_or(serde_json::Value::Null, |s| serde_json::Value::String(s.clone())),
730        );
731        data.insert(
732            "attached_project".into(),
733            detail
734                .attached_project
735                .as_ref()
736                .map_or(serde_json::Value::Null, |s| serde_json::Value::String(s.clone())),
737        );
738        data.insert(
739            "owner".into(),
740            detail
741                .owner
742                .as_ref()
743                .map_or(serde_json::Value::Null, |s| serde_json::Value::String(s.clone())),
744        );
745        data.insert(
746            "visibility".into(),
747            detail
748                .visibility
749                .as_ref()
750                .map_or(serde_json::Value::Null, |v| serde_json::Value::String(v.as_str().into())),
751        );
752        data.insert(
753            "your_access".into(),
754            detail
755                .your_access
756                .as_ref()
757                .map_or(serde_json::Value::Null, |a| serde_json::Value::String(a.as_str().into())),
758        );
759        // `values` key is present only when --values was set (detail.values is Some).
760        // Absent (not null) when None — preserves 8b envelope byte-for-byte.
761        if let Some(ref fields) = detail.values {
762            let values_arr: Vec<serde_json::Value> = fields
763                .iter()
764                .map(|fv| {
765                    let value_objs: Vec<serde_json::Value> = fv
766                        .values
767                        .iter()
768                        .map(|v| {
769                            let mut obj = value_content_to_json(&v.content);
770                            if let (Some(c), serde_json::Value::Object(m)) = (&v.comment, &mut obj) {
771                                m.insert("comment".into(), serde_json::Value::String(c.clone()));
772                            }
773                            obj
774                        })
775                        .collect();
776                    let mut fg = serde_json::Map::new();
777                    fg.insert("field".into(), serde_json::Value::String(fv.name.clone()));
778                    let fl_val = match &fv.label {
779                        Some(s) => serde_json::Value::String(s.clone()),
780                        None => serde_json::Value::Null,
781                    };
782                    fg.insert("field_label".into(), fl_val);
783                    fg.insert("values".into(), serde_json::Value::Array(value_objs));
784                    serde_json::Value::Object(fg)
785                })
786                .collect();
787            data.insert("values".into(), serde_json::Value::Array(values_arr));
788        }
789
790        let obj = json!({
791            "_meta": meta_obj,
792            "data": serde_json::Value::Object(data),
793        });
794        writeln!(
795            self.out,
796            "{}",
797            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
798        )?;
799        Ok(())
800    }
801
802    fn vocabularies(&mut self, view: &VocabularyListView, meta: &MetaContext) -> Result<(), Diagnostic> {
803        // Build data array. `labels`/`comments` are the verbatim, lossless
804        // `{value, language}` arrays (json stays lossless — no per-language
805        // column collapsing, unlike tabular). `nodes`/`depth` (plan 034) are
806        // DELIBERATELY omitted (not emitted as null) when the item carries no
807        // count — mirrors `resource_types`'s `count` omission convention.
808        let data: Vec<serde_json::Value> = view
809            .items
810            .iter()
811            .map(|item| {
812                let labels = localized_text_array(&item.header.labels);
813                let comments = localized_text_array(&item.header.comments);
814                let mut obj = json!({
815                    "name": item.header.name,
816                    "iri": item.header.iri,
817                    "labels": labels,
818                    "comments": comments,
819                });
820                if let Some(n) = item.node_count {
821                    obj["nodes"] = serde_json::Value::from(n);
822                }
823                if let Some(d) = item.depth {
824                    obj["depth"] = serde_json::Value::from(d);
825                }
826                obj
827            })
828            .collect();
829
830        // D3-style: add `note` to _meta when count_cost is Some (plan 034;
831        // mirrors the count_caveat assignment above for `resource_types`).
832        let mut meta_obj = meta_block(meta, 0);
833        if let Some(ref cc) = meta.count_cost {
834            meta_obj["note"] = serde_json::Value::from(cc.as_str());
835        }
836
837        let obj = json!({
838            "_meta": meta_obj,
839            "data": data,
840        });
841        writeln!(
842            self.out,
843            "{}",
844            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
845        )?;
846        Ok(())
847    }
848
849    fn vocabulary_describe(&mut self, detail: &VocabularyDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
850        // dsp-cli/ADR-0003 single-object envelope. `nodes`/`depth` are ALWAYS present
851        // here (plain `usize` on `VocabularyDetail`, unlike `list`'s Option) —
852        // the omit-when-absent rule above does not apply.
853        //
854        // Per-node array key: named `"children"` (not `"nodes"`, which would
855        // collide with the top-level node-count key `data.nodes`) — chosen to
856        // match the model's own `VocabularyTree.children`/`VocabularyNode.children`
857        // naming: json's `data.children` IS a nested tree, mirroring the
858        // model shape one-to-one (plan 034 review fix — json is the lossless
859        // path, so it renders the tree AS a tree rather than flattening it
860        // like the tabular formats do). `path`/`depth`/`parent_iri` are
861        // dropped here (structural/derivable from nesting); `number` and
862        // `position` are kept.
863        fn nested_to_json(node: &NestedVocabularyNode<'_>) -> serde_json::Value {
864            let h = node.header;
865            json!({
866                "node_iri": h.iri,
867                "number": node.number,
868                "name": h.name,
869                "labels": localized_text_array(&h.labels),
870                "comments": localized_text_array(&h.comments),
871                "position": node.position,
872                "children": node.children.iter().map(nested_to_json).collect::<Vec<_>>(),
873            })
874        }
875
876        let root = &detail.tree.root;
877        let children: Vec<serde_json::Value> =
878            nest_vocabulary_detail(detail).iter().map(nested_to_json).collect::<Vec<_>>();
879
880        let mut data = json!({
881            "name": root.name,
882            "iri": root.iri,
883            "labels": localized_text_array(&root.labels),
884            "comments": localized_text_array(&root.comments),
885            "nodes": detail.node_count,
886            "depth": detail.depth,
887            "children": children,
888        });
889        // `requested_node`/`subtree_of`: omitted (not null) when not
890        // applicable — there is no separate boolean, `subtree_of` being
891        // present IS the `--subtree` flag.
892        if let Some(ref requested) = detail.tree.requested_node {
893            data["requested_node"] = serde_json::Value::from(requested.as_str());
894        }
895        if let Some(ref subtree_of) = detail.subtree_of {
896            data["subtree_of"] = serde_json::Value::from(subtree_of.as_str());
897        }
898
899        let obj = json!({
900            "_meta": meta_block(meta, 0),
901            "data": data,
902        });
903        writeln!(
904            self.out,
905            "{}",
906            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
907        )?;
908        Ok(())
909    }
910}
911
912#[cfg(test)]
913mod tests {
914    use super::*;
915    use crate::model::{
916        DataModel, DataModelDetail, DataModelSummary, Project, ProjectDescription, ProjectDetail, ResourceType,
917        ResourceTypeSummary,
918    };
919    use crate::render::test_support::{SharedBuf, make_meta};
920    use crate::render::{DataModelListView, ResourceTypeListView};
921
922    #[test]
923    fn meta_block_full_context_has_all_three_keys() {
924        // Non-empty server_label/auth_state (the shape every success path uses)
925        // → server, auth, and exit_code all present.
926        let meta = MetaContext {
927            server_label: "https://api.test.dasch.swiss".into(),
928            auth_state: "anonymous".into(),
929            filter_warning: None,
930            count_caveat: None,
931            count_cost: None,
932        };
933        let block = meta_block(&meta, 1);
934        let obj = block.as_object().unwrap();
935        assert_eq!(obj["server"], "https://api.test.dasch.swiss");
936        assert_eq!(obj["auth"], "anonymous");
937        assert_eq!(obj["exit_code"], 1);
938        assert_eq!(obj.len(), 3, "expected exactly three keys; got: {block}");
939    }
940
941    #[test]
942    fn meta_block_empty_server_and_auth_omits_both_keys() {
943        // Empty server_label/auth_state (the top-level-error shape, plan 032 D3)
944        // → only exit_code is present; server/auth are absent (not null).
945        let meta = MetaContext {
946            server_label: String::new(),
947            auth_state: String::new(),
948            filter_warning: None,
949            count_caveat: None,
950            count_cost: None,
951        };
952        let block = meta_block(&meta, 2);
953        let obj = block.as_object().unwrap();
954        assert!(
955            !obj.contains_key("server"),
956            "server key must be absent when server_label is empty; got: {block}"
957        );
958        assert!(
959            !obj.contains_key("auth"),
960            "auth key must be absent when auth_state is empty; got: {block}"
961        );
962        assert_eq!(obj["exit_code"], 2);
963        assert_eq!(obj.len(), 1, "expected exactly one key; got: {block}");
964    }
965
966    #[test]
967    fn projects_json_output() {
968        let out = SharedBuf::new();
969        let mut renderer = JsonRenderer::with_writer(out.clone());
970        let items = vec![
971            Project {
972                iri: "http://rdfh.ch/projects/0001".into(),
973                shortcode: "0001".into(),
974                shortname: "anything".into(),
975                longname: Some("Anything Project".into()),
976                data_models: 2,
977            },
978            Project {
979                iri: "http://rdfh.ch/projects/0002".into(),
980                shortcode: "0002".into(),
981                shortname: "images".into(),
982                longname: None,
983                data_models: 0,
984            },
985        ];
986        let view = ProjectListView { items, total: 2, filter: None };
987        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
988        renderer.projects(&view, &meta).unwrap();
989
990        let s = out.string();
991        let parsed: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
992
993        // _meta present
994        assert_eq!(parsed["_meta"]["auth"], "anonymous");
995        assert_eq!(parsed["_meta"]["server"], "https://api.test.dasch.swiss");
996        assert_eq!(parsed["_meta"]["exit_code"], 0);
997
998        // data is array
999        let data = parsed["data"].as_array().unwrap();
1000        assert_eq!(data.len(), 2);
1001
1002        // first item
1003        assert_eq!(data[0]["shortcode"], "0001");
1004        assert_eq!(data[0]["shortname"], "anything");
1005        assert_eq!(data[0]["longname"], "Anything Project");
1006        assert_eq!(data[0]["data_models"], 2);
1007        assert_eq!(data[0]["iri"], "http://rdfh.ch/projects/0001");
1008
1009        // second item — longname None → null
1010        assert_eq!(data[1]["shortcode"], "0002");
1011        assert!(data[1]["longname"].is_null());
1012        assert_eq!(data[1]["data_models"], 0);
1013    }
1014
1015    #[test]
1016    fn projects_json_empty_data_array() {
1017        let out = SharedBuf::new();
1018        let mut renderer = JsonRenderer::with_writer(out.clone());
1019        let view = ProjectListView { items: vec![], total: 0, filter: None };
1020        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1021        renderer.projects(&view, &meta).unwrap();
1022
1023        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1024        assert!(parsed["data"].as_array().unwrap().is_empty());
1025    }
1026
1027    /// Assert that `diagnostic_kind` returns the correct stable string for every
1028    /// `Diagnostic` variant. This test is intentionally exhaustive: adding a new
1029    /// variant without a corresponding arm in `diagnostic_kind` will cause a
1030    /// compiler warning (non-exhaustive match) at the match site, but this test
1031    /// ensures the mapping is also exercised at the call level so the kind string
1032    /// is verified, not just the pattern.
1033    #[test]
1034    fn diagnostic_kind_covers_all_variants() {
1035        let cases: &[(&Diagnostic, &str)] = &[
1036            (&Diagnostic::Usage("x".into()), "usage"),
1037            (&Diagnostic::AuthRequired("x".into()), "auth_required"),
1038            (&Diagnostic::NotFound("x".into()), "not_found"),
1039            (&Diagnostic::ServerError("x".into()), "server_error"),
1040            (&Diagnostic::Network("x".into()), "network"),
1041            (&Diagnostic::Conflict("x".into()), "conflict"),
1042            (&Diagnostic::Io("x".into()), "io"),
1043            (&Diagnostic::Internal("x".into()), "internal"),
1044            (&Diagnostic::NotImplemented("x".into()), "internal"),
1045        ];
1046        for (diag, expected_kind) in cases {
1047            assert_eq!(diagnostic_kind(diag), *expected_kind, "unexpected kind for {diag:?}");
1048        }
1049    }
1050
1051    #[test]
1052    fn conflict_kind_is_conflict() {
1053        let d = Diagnostic::Conflict("dump already in progress".into());
1054        assert_eq!(diagnostic_kind(&d), "conflict");
1055    }
1056
1057    #[test]
1058    fn io_kind_is_io() {
1059        let d = Diagnostic::Io("failed to write /tmp/0001.zip: permission denied".into());
1060        assert_eq!(diagnostic_kind(&d), "io");
1061    }
1062
1063    /// Pins the `kind` / `_meta.exit_code` mapping for a NON-usage `Diagnostic`
1064    /// via `JsonRenderer::diagnostic` (plan 032's Test plan). The binary-level
1065    /// tests in `tests/cli.rs` only exercise usage errors (exit code 2, no
1066    /// server needed); this test covers a Runtime-category kind (exit code 1)
1067    /// generically, without a server, so a hard-to-trigger non-network exit-3
1068    /// case isn't required.
1069    #[test]
1070    fn diagnostic_not_found_json_output() {
1071        let out = SharedBuf::new();
1072        let mut renderer = JsonRenderer::with_writer(out.clone());
1073        let diag = Diagnostic::NotFound("resource http://rdfh.ch/0001/xyz not found".into());
1074        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1075        renderer.diagnostic(&diag, &meta).unwrap();
1076
1077        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1078        assert_eq!(parsed["error"]["kind"], "not_found");
1079        assert_eq!(parsed["_meta"]["exit_code"], 1);
1080        let message = parsed["error"]["message"].as_str().expect("error.message must be a string");
1081        assert!(!message.is_empty(), "error.message must be non-empty");
1082        assert_eq!(message, diag.to_string());
1083    }
1084
1085    fn make_beol_detail() -> ProjectDetail {
1086        // Four data-models passed in already-sorted order (client layer sorts;
1087        // renderer passes them through as-is). Having all four here guards against
1088        // a renderer that truncates or reorders the slice.
1089        ProjectDetail {
1090            iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
1091            shortcode: "0801".into(),
1092            shortname: "beol".into(),
1093            longname: Some("Bernoulli-Euler Online".into()),
1094            description: vec![ProjectDescription {
1095                value: "<b>BEOL</b> — early modern mathematics.".into(),
1096                language: Some("en".into()),
1097            }],
1098            keywords: vec!["Bernoulli".into(), "Euler".into(), "Mathematics".into()],
1099            data_models: vec![
1100                DataModelSummary {
1101                    name: "beol".into(),
1102                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1103                },
1104                DataModelSummary {
1105                    name: "biblio".into(),
1106                    iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1107                },
1108                DataModelSummary {
1109                    name: "leibniz".into(),
1110                    iri: "http://api.dasch.swiss/ontology/0801/leibniz/v2".into(),
1111                },
1112                DataModelSummary {
1113                    name: "newton".into(),
1114                    iri: "http://api.dasch.swiss/ontology/0801/newton/v2".into(),
1115                },
1116            ],
1117        }
1118    }
1119
1120    #[test]
1121    fn project_describe_json_full() {
1122        let out = SharedBuf::new();
1123        let mut renderer = JsonRenderer::with_writer(out.clone());
1124        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1125        renderer.project_describe(&make_beol_detail(), &meta).unwrap();
1126
1127        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1128
1129        // _meta present
1130        assert_eq!(parsed["_meta"]["auth"], "anonymous");
1131        assert_eq!(parsed["_meta"]["exit_code"], 0);
1132
1133        // data is a single object
1134        let data = &parsed["data"];
1135        assert!(data.is_object());
1136        assert_eq!(data["iri"], "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF");
1137        assert_eq!(data["shortcode"], "0801");
1138        assert_eq!(data["shortname"], "beol");
1139        assert_eq!(data["longname"], "Bernoulli-Euler Online");
1140
1141        // description array
1142        let desc = data["description"].as_array().unwrap();
1143        assert_eq!(desc.len(), 1);
1144        assert_eq!(desc[0]["value"], "<b>BEOL</b> — early modern mathematics.");
1145        assert_eq!(desc[0]["language"], "en");
1146
1147        // keywords array
1148        let kws = data["keywords"].as_array().unwrap();
1149        assert_eq!(kws.len(), 3);
1150        assert_eq!(kws[0], "Bernoulli");
1151
1152        // data_models array — four entries in sorted order (beol, biblio, leibniz, newton).
1153        // Guards against a renderer that truncates or reorders the slice.
1154        let dms = data["data_models"].as_array().unwrap();
1155        assert_eq!(dms.len(), 4, "all four data-models must be rendered");
1156        assert_eq!(dms[0]["name"], "beol");
1157        assert_eq!(dms[0]["iri"], "http://api.dasch.swiss/ontology/0801/beol/v2");
1158        assert_eq!(dms[1]["name"], "biblio");
1159        assert_eq!(dms[2]["name"], "leibniz");
1160        assert_eq!(dms[3]["name"], "newton");
1161    }
1162
1163    #[test]
1164    fn project_describe_json_no_longname() {
1165        let out = SharedBuf::new();
1166        let mut renderer = JsonRenderer::with_writer(out.clone());
1167        let mut detail = make_beol_detail();
1168        detail.longname = None;
1169        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1170        renderer.project_describe(&detail, &meta).unwrap();
1171
1172        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1173        // longname None → JSON null
1174        assert!(parsed["data"]["longname"].is_null());
1175    }
1176
1177    #[test]
1178    fn project_describe_json_empty_fields() {
1179        let out = SharedBuf::new();
1180        let mut renderer = JsonRenderer::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            description: vec![],
1187            keywords: vec![],
1188            data_models: vec![],
1189        };
1190        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1191        renderer.project_describe(&detail, &meta).unwrap();
1192
1193        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1194        let data = &parsed["data"];
1195        assert!(data["description"].as_array().unwrap().is_empty());
1196        assert!(data["keywords"].as_array().unwrap().is_empty());
1197        assert!(data["data_models"].as_array().unwrap().is_empty());
1198    }
1199
1200    fn make_data_model_fixture() -> Vec<DataModel> {
1201        vec![
1202            DataModel {
1203                name: "beol".into(),
1204                iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1205                label: Some("The BEOL data-model".into()),
1206                last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1207                is_builtin: false,
1208            },
1209            DataModel {
1210                name: "biblio".into(),
1211                iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1212                label: None,
1213                last_modified: None,
1214                is_builtin: false,
1215            },
1216            DataModel {
1217                name: "knora-api".into(),
1218                iri: "http://api.knora.org/ontology/knora-api/v2".into(),
1219                label: None,
1220                last_modified: None,
1221                is_builtin: true,
1222            },
1223        ]
1224    }
1225
1226    #[test]
1227    fn data_models_json_output() {
1228        let out = SharedBuf::new();
1229        let mut renderer = JsonRenderer::with_writer(out.clone());
1230        let view = DataModelListView { items: make_data_model_fixture(), total: 3, filter: None };
1231        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1232        renderer.data_models(&view, &meta).unwrap();
1233
1234        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1235
1236        // _meta present
1237        assert_eq!(parsed["_meta"]["auth"], "anonymous");
1238        assert_eq!(parsed["_meta"]["server"], "https://api.test.dasch.swiss");
1239        assert_eq!(parsed["_meta"]["exit_code"], 0);
1240
1241        // data is an array
1242        let data = parsed["data"].as_array().unwrap();
1243        assert_eq!(data.len(), 3);
1244
1245        // first item: has label and last_modified, not builtin
1246        assert_eq!(data[0]["name"], "beol");
1247        assert_eq!(data[0]["iri"], "http://api.dasch.swiss/ontology/0801/beol/v2");
1248        assert_eq!(data[0]["label"], "The BEOL data-model");
1249        assert_eq!(data[0]["last_modified"], "2024-05-27T13:43:26.233048Z");
1250        assert_eq!(data[0]["is_builtin"], false);
1251
1252        // second item: label None → null, last_modified None → null
1253        assert_eq!(data[1]["name"], "biblio");
1254        assert!(data[1]["label"].is_null());
1255        assert!(data[1]["last_modified"].is_null());
1256        assert_eq!(data[1]["is_builtin"], false);
1257
1258        // third item: builtin, both null
1259        assert_eq!(data[2]["name"], "knora-api");
1260        assert!(data[2]["label"].is_null());
1261        assert!(data[2]["last_modified"].is_null());
1262        assert_eq!(data[2]["is_builtin"], true);
1263    }
1264
1265    #[test]
1266    fn data_models_json_empty_data_array() {
1267        let out = SharedBuf::new();
1268        let mut renderer = JsonRenderer::with_writer(out.clone());
1269        let view = DataModelListView { items: vec![], total: 0, filter: None };
1270        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1271        renderer.data_models(&view, &meta).unwrap();
1272
1273        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1274        assert!(parsed["data"].as_array().unwrap().is_empty());
1275    }
1276
1277    // ── data_model_describe JSON tests ────────────────────────────────────────
1278
1279    fn make_beol_dm_detail() -> DataModelDetail {
1280        DataModelDetail {
1281            name: "beol".into(),
1282            iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1283            label: Some("The BEOL data-model".into()),
1284            last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1285            resource_types: vec![
1286                ResourceTypeSummary {
1287                    name: "Archive".into(),
1288                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1289                    label: Some("Archive".into()),
1290                },
1291                ResourceTypeSummary {
1292                    name: "basicLetter".into(),
1293                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
1294                    label: None,
1295                },
1296                ResourceTypeSummary {
1297                    name: "letter".into(),
1298                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1299                    label: Some("Letter".into()),
1300                },
1301            ],
1302        }
1303    }
1304
1305    #[test]
1306    fn data_model_describe_json_full() {
1307        let out = SharedBuf::new();
1308        let mut renderer = JsonRenderer::with_writer(out.clone());
1309        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1310        renderer.data_model_describe(&make_beol_dm_detail(), &meta).unwrap();
1311
1312        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1313
1314        // _meta present
1315        assert_eq!(parsed["_meta"]["auth"], "anonymous");
1316        assert_eq!(parsed["_meta"]["server"], "https://api.test.dasch.swiss");
1317        assert_eq!(parsed["_meta"]["exit_code"], 0);
1318
1319        // data is a single object (dsp-cli/ADR-0003)
1320        let data = &parsed["data"];
1321        assert!(data.is_object());
1322        assert_eq!(data["name"], "beol");
1323        assert_eq!(data["iri"], "http://api.dasch.swiss/ontology/0801/beol/v2");
1324        assert_eq!(data["label"], "The BEOL data-model");
1325        // last_modified is the full RFC3339 string (lossless)
1326        assert_eq!(data["last_modified"], "2024-05-27T13:43:26.233048Z");
1327
1328        // resource_types array
1329        let rts = data["resource_types"].as_array().unwrap();
1330        assert_eq!(rts.len(), 3);
1331        assert_eq!(rts[0]["name"], "Archive");
1332        assert_eq!(rts[0]["iri"], "http://api.dasch.swiss/ontology/0801/beol/v2#Archive");
1333        assert_eq!(rts[0]["label"], "Archive");
1334        // label None → null
1335        assert_eq!(rts[1]["name"], "basicLetter");
1336        assert!(rts[1]["label"].is_null());
1337        assert_eq!(rts[2]["name"], "letter");
1338        assert_eq!(rts[2]["label"], "Letter");
1339    }
1340
1341    #[test]
1342    fn data_model_describe_json_no_label_no_last_modified() {
1343        let out = SharedBuf::new();
1344        let mut renderer = JsonRenderer::with_writer(out.clone());
1345        let detail = DataModelDetail {
1346            name: "minimal".into(),
1347            iri: "http://api.dasch.swiss/ontology/0000/minimal/v2".into(),
1348            label: None,
1349            last_modified: None,
1350            resource_types: vec![],
1351        };
1352        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1353        renderer.data_model_describe(&detail, &meta).unwrap();
1354
1355        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1356        let data = &parsed["data"];
1357        // label None → null
1358        assert!(data["label"].is_null());
1359        // last_modified None → null
1360        assert!(data["last_modified"].is_null());
1361        // resource_types empty array
1362        assert!(data["resource_types"].as_array().unwrap().is_empty());
1363    }
1364
1365    // ── resource_types JSON tests ─────────────────────────────────────────────
1366
1367    fn make_rt_fixture() -> Vec<ResourceType> {
1368        vec![
1369            ResourceType {
1370                name: "Archive".into(),
1371                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1372                label: Some("Archive".into()),
1373                is_builtin: false,
1374                count: None,
1375            },
1376            ResourceType {
1377                name: "letter".into(),
1378                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1379                label: None,
1380                is_builtin: false,
1381                count: None,
1382            },
1383        ]
1384    }
1385
1386    #[test]
1387    fn resource_types_json_output() {
1388        let out = SharedBuf::new();
1389        let mut renderer = JsonRenderer::with_writer(out.clone());
1390        let view = ResourceTypeListView {
1391            items: make_rt_fixture(),
1392            total: 2,
1393            filter: None,
1394            data_model: "beol".into(),
1395        };
1396        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1397        renderer.resource_types(&view, &meta).unwrap();
1398
1399        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1400
1401        // _meta present
1402        assert_eq!(parsed["_meta"]["auth"], "anonymous");
1403        assert_eq!(parsed["_meta"]["server"], "https://api.test.dasch.swiss");
1404        assert_eq!(parsed["_meta"]["exit_code"], 0);
1405
1406        // data is an array
1407        let data = parsed["data"].as_array().unwrap();
1408        assert_eq!(data.len(), 2);
1409
1410        // first item: has label, not builtin
1411        assert_eq!(data[0]["name"], "Archive");
1412        assert_eq!(data[0]["iri"], "http://api.dasch.swiss/ontology/0801/beol/v2#Archive");
1413        assert_eq!(data[0]["label"], "Archive");
1414        assert_eq!(data[0]["is_builtin"], false);
1415
1416        // second item: label None → null
1417        assert_eq!(data[1]["name"], "letter");
1418        assert!(data[1]["label"].is_null());
1419        assert_eq!(data[1]["is_builtin"], false);
1420    }
1421
1422    #[test]
1423    fn resource_types_json_with_builtins() {
1424        let out = SharedBuf::new();
1425        let mut renderer = JsonRenderer::with_writer(out.clone());
1426        let view = ResourceTypeListView {
1427            items: vec![ResourceType {
1428                name: "Region".into(),
1429                iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1430                label: Some("Region".into()),
1431                is_builtin: true,
1432                count: None,
1433            }],
1434            total: 1,
1435            filter: None,
1436            data_model: "beol".into(),
1437        };
1438        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1439        renderer.resource_types(&view, &meta).unwrap();
1440
1441        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1442        let data = parsed["data"].as_array().unwrap();
1443        assert_eq!(data.len(), 1);
1444        assert_eq!(data[0]["name"], "Region");
1445        assert_eq!(data[0]["label"], "Region");
1446        // is_builtin must be true (a bool, not a string)
1447        assert_eq!(data[0]["is_builtin"], true);
1448    }
1449
1450    #[test]
1451    fn resource_types_json_with_filter() {
1452        // filter does not affect JSON output shape; just verify it renders cleanly
1453        let out = SharedBuf::new();
1454        let mut renderer = JsonRenderer::with_writer(out.clone());
1455        let view = ResourceTypeListView {
1456            items: vec![make_rt_fixture().remove(0)], // just Archive
1457            total: 2,
1458            filter: Some("arch".to_string()),
1459            data_model: "beol".into(),
1460        };
1461        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1462        renderer.resource_types(&view, &meta).unwrap();
1463
1464        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1465        let data = parsed["data"].as_array().unwrap();
1466        assert_eq!(data.len(), 1);
1467        assert_eq!(data[0]["name"], "Archive");
1468    }
1469
1470    #[test]
1471    fn resource_types_json_empty_data_array() {
1472        let out = SharedBuf::new();
1473        let mut renderer = JsonRenderer::with_writer(out.clone());
1474        let view = ResourceTypeListView {
1475            items: vec![],
1476            total: 0,
1477            filter: None,
1478            data_model: "beol".into(),
1479        };
1480        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1481        renderer.resource_types(&view, &meta).unwrap();
1482
1483        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1484        assert!(parsed["data"].as_array().unwrap().is_empty());
1485    }
1486
1487    #[test]
1488    fn resource_types_json_with_count_and_caveat() {
1489        // plan 030: `count` key present (numeric) when Some, `_meta.note`
1490        // carries `count_caveat` when Some.
1491        let out = SharedBuf::new();
1492        let mut renderer = JsonRenderer::with_writer(out.clone());
1493        let mut items = make_rt_fixture();
1494        items[0].count = Some(5);
1495        let view = ResourceTypeListView { items, total: 2, filter: None, data_model: "beol".into() };
1496        let meta = crate::render::MetaContext {
1497            server_label: "https://api.test.dasch.swiss".into(),
1498            auth_state: "anonymous".into(),
1499            filter_warning: None,
1500            count_caveat: Some("counts are not permission-filtered".into()),
1501            count_cost: None,
1502        };
1503        renderer.resource_types(&view, &meta).unwrap();
1504
1505        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1506        assert_eq!(parsed["data"][0]["count"], 5);
1507        // Second item has no count → key absent (not null).
1508        assert!(
1509            !parsed["data"][1].as_object().unwrap().contains_key("count"),
1510            "count key must be absent when None; got: {}",
1511            parsed["data"][1]
1512        );
1513        assert_eq!(
1514            parsed["_meta"]["note"], "counts are not permission-filtered",
1515            "note must carry count_caveat"
1516        );
1517    }
1518
1519    #[test]
1520    fn resource_types_json_no_count_no_note() {
1521        // Regression: count_caveat: None (today's only production case) →
1522        // no `note` key, no `count` key on any item.
1523        let out = SharedBuf::new();
1524        let mut renderer = JsonRenderer::with_writer(out.clone());
1525        let view = ResourceTypeListView {
1526            items: make_rt_fixture(),
1527            total: 2,
1528            filter: None,
1529            data_model: "beol".into(),
1530        };
1531        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1532        renderer.resource_types(&view, &meta).unwrap();
1533
1534        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1535        assert!(!parsed["_meta"].as_object().unwrap().contains_key("note"));
1536        assert!(!parsed["data"][0].as_object().unwrap().contains_key("count"));
1537    }
1538
1539    // ── resource_type_describe JSON tests ─────────────────────────────────────
1540
1541    fn make_minimal_rt_detail() -> crate::model::ResourceTypeDetail {
1542        crate::model::ResourceTypeDetail {
1543            name: "manuscript".into(),
1544            iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1545            label: Some("Manuscript".into()),
1546            data_model: "beol".into(),
1547            representation: None,
1548            super_types: vec![],
1549            fields: vec![],
1550            count: None,
1551        }
1552    }
1553
1554    #[test]
1555    fn resource_type_describe_json_with_count_and_caveat() {
1556        let out = SharedBuf::new();
1557        let mut renderer = JsonRenderer::with_writer(out.clone());
1558        let mut detail = make_minimal_rt_detail();
1559        detail.count = Some(99);
1560        let meta = crate::render::MetaContext {
1561            server_label: "https://api.dasch.swiss".into(),
1562            auth_state: "anonymous".into(),
1563            filter_warning: None,
1564            count_caveat: Some("counts exclude deleted resources".into()),
1565            count_cost: None,
1566        };
1567        renderer.resource_type_describe(&detail, &meta).unwrap();
1568
1569        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1570        assert_eq!(parsed["data"]["count"], 99);
1571        assert_eq!(
1572            parsed["_meta"]["note"], "counts exclude deleted resources",
1573            "note must carry count_caveat"
1574        );
1575    }
1576
1577    #[test]
1578    fn resource_type_describe_json_no_count_no_note() {
1579        // Regression: count: None / count_caveat: None (today's only
1580        // production case) → no `count` key, no `note` key.
1581        let out = SharedBuf::new();
1582        let mut renderer = JsonRenderer::with_writer(out.clone());
1583        let meta = make_meta("anonymous", "https://api.dasch.swiss");
1584        renderer.resource_type_describe(&make_minimal_rt_detail(), &meta).unwrap();
1585
1586        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1587        assert!(!parsed["data"].as_object().unwrap().contains_key("count"));
1588        assert!(!parsed["_meta"].as_object().unwrap().contains_key("note"));
1589    }
1590
1591    // ── resource_describe JSON values tests ───────────────────────────────────
1592
1593    use crate::model::resource_type::ValueType;
1594    use crate::model::{
1595        DatePoint, DateValue, FieldValues, FileValue, ResourceAccess, ResourceDetail, ResourceVisibility, Value,
1596        ValueContent,
1597    };
1598
1599    fn make_resource_detail_no_values() -> ResourceDetail {
1600        ResourceDetail {
1601            label: "Test Resource".into(),
1602            iri: "http://rdfh.ch/0803/abc123".into(),
1603            resource_type: "Page".into(),
1604            ark_url: None,
1605            creation_date: None,
1606            last_modified: None,
1607            attached_project: None,
1608            owner: None,
1609            visibility: Some(ResourceVisibility::Public),
1610            your_access: Some(ResourceAccess::View),
1611            values: None,
1612        }
1613    }
1614
1615    #[test]
1616    fn resource_describe_json_no_values_key_absent() {
1617        // When values is None, the "values" key must be ABSENT (not null) in json output.
1618        let out = SharedBuf::new();
1619        let mut renderer = JsonRenderer::with_writer(out.clone());
1620        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1621        renderer.resource_describe(&make_resource_detail_no_values(), &meta).unwrap();
1622
1623        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1624        let data = &parsed["data"];
1625        assert!(
1626            data["values"].is_null() && !data.as_object().unwrap().contains_key("values"),
1627            "values key must be absent when values is None; got data: {data}"
1628        );
1629    }
1630
1631    #[test]
1632    fn resource_describe_json_some_values_array_present() {
1633        // When values is Some, the "values" key must be present in json data.
1634        let out = SharedBuf::new();
1635        let mut renderer = JsonRenderer::with_writer(out.clone());
1636        let mut detail = make_resource_detail_no_values();
1637        detail.values = Some(vec![FieldValues {
1638            name: "hasTitle".into(),
1639            label: Some("Title".into()),
1640            values: vec![ValueContent::Text("Hello".into()).into()],
1641        }]);
1642        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1643        renderer.resource_describe(&detail, &meta).unwrap();
1644
1645        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1646        let data = &parsed["data"];
1647        assert!(
1648            data.as_object().unwrap().contains_key("values"),
1649            "values key must be present when values is Some; got data: {data}"
1650        );
1651        let values = data["values"].as_array().unwrap();
1652        assert_eq!(values.len(), 1);
1653        assert_eq!(values[0]["field"], "hasTitle");
1654        assert_eq!(values[0]["field_label"], "Title");
1655        let val_objs = values[0]["values"].as_array().unwrap();
1656        assert_eq!(val_objs.len(), 1);
1657        assert_eq!(val_objs[0]["value_type"], "text");
1658        assert_eq!(val_objs[0]["text"], "Hello");
1659    }
1660
1661    #[test]
1662    fn resource_describe_json_some_empty_values_array() {
1663        // Some(vec![]) → "values": [] — key present, empty array.
1664        let out = SharedBuf::new();
1665        let mut renderer = JsonRenderer::with_writer(out.clone());
1666        let mut detail = make_resource_detail_no_values();
1667        detail.values = Some(vec![]);
1668        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1669        renderer.resource_describe(&detail, &meta).unwrap();
1670
1671        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1672        let data = &parsed["data"];
1673        assert!(
1674            data.as_object().unwrap().contains_key("values"),
1675            "values key must be present even for empty Some(vec![]);"
1676        );
1677        assert!(data["values"].as_array().unwrap().is_empty());
1678    }
1679
1680    #[test]
1681    fn resource_describe_json_field_label_null_when_none() {
1682        // field_label null when label is None.
1683        let out = SharedBuf::new();
1684        let mut renderer = JsonRenderer::with_writer(out.clone());
1685        let mut detail = make_resource_detail_no_values();
1686        detail.values = Some(vec![FieldValues {
1687            name: "seqnum".into(),
1688            label: None,
1689            values: vec![ValueContent::Integer(42).into()],
1690        }]);
1691        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1692        renderer.resource_describe(&detail, &meta).unwrap();
1693
1694        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1695        let fg = &parsed["data"]["values"][0];
1696        assert!(fg["field_label"].is_null(), "field_label must be null when label is None");
1697        let val = &fg["values"][0];
1698        assert_eq!(val["value_type"], "integer");
1699        assert_eq!(val["value"], 42);
1700    }
1701
1702    #[test]
1703    fn resource_describe_json_link_value() {
1704        let out = SharedBuf::new();
1705        let mut renderer = JsonRenderer::with_writer(out.clone());
1706        let mut detail = make_resource_detail_no_values();
1707        detail.values = Some(vec![FieldValues {
1708            name: "isPartOf".into(),
1709            label: None,
1710            values: vec![
1711                ValueContent::Link {
1712                    target_iri: "http://rdfh.ch/0803/book1".into(),
1713                    target_label: Some("My Book".into()),
1714                }
1715                .into(),
1716                ValueContent::Link {
1717                    target_iri: "http://rdfh.ch/0803/book2".into(),
1718                    target_label: None,
1719                }
1720                .into(),
1721            ],
1722        }]);
1723        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1724        renderer.resource_describe(&detail, &meta).unwrap();
1725
1726        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1727        let vals = &parsed["data"]["values"][0]["values"];
1728        // first: with label
1729        assert_eq!(vals[0]["value_type"], "link");
1730        assert_eq!(vals[0]["target_iri"], "http://rdfh.ch/0803/book1");
1731        assert_eq!(vals[0]["target_label"], "My Book");
1732        // second: no label → null
1733        assert_eq!(vals[1]["value_type"], "link");
1734        assert_eq!(vals[1]["target_iri"], "http://rdfh.ch/0803/book2");
1735        assert!(vals[1]["target_label"].is_null());
1736    }
1737
1738    #[test]
1739    fn resource_describe_json_still_image_value() {
1740        let out = SharedBuf::new();
1741        let mut renderer = JsonRenderer::with_writer(out.clone());
1742        let mut detail = make_resource_detail_no_values();
1743        detail.values = Some(vec![FieldValues {
1744            name: "hasStillImageFileValue".into(),
1745            label: None,
1746            values: vec![
1747                ValueContent::File(FileValue {
1748                    value_type: ValueType::StillImage,
1749                    filename: "image.jp2".into(),
1750                    url: "https://iiif.example.com/image.jp2/full/max/0/default.jpg".into(),
1751                    width: Some(1200),
1752                    height: Some(800),
1753                })
1754                .into(),
1755            ],
1756        }]);
1757        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1758        renderer.resource_describe(&detail, &meta).unwrap();
1759
1760        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1761        let val = &parsed["data"]["values"][0]["values"][0];
1762        assert_eq!(val["value_type"], "still-image");
1763        assert_eq!(val["filename"], "image.jp2");
1764        assert_eq!(val["url"], "https://iiif.example.com/image.jp2/full/max/0/default.jpg");
1765        assert_eq!(val["width"], 1200);
1766        assert_eq!(val["height"], 800);
1767    }
1768
1769    #[test]
1770    fn resource_describe_json_date_value() {
1771        let out = SharedBuf::new();
1772        let mut renderer = JsonRenderer::with_writer(out.clone());
1773        let mut detail = make_resource_detail_no_values();
1774        let pt = DatePoint {
1775            year: Some(1489),
1776            month: None,
1777            day: None,
1778            era: Some("CE".into()),
1779        };
1780        detail.values = Some(vec![FieldValues {
1781            name: "hasDate".into(),
1782            label: None,
1783            values: vec![
1784                ValueContent::Date(DateValue {
1785                    calendar: "GREGORIAN".into(),
1786                    start: pt.clone(),
1787                    end: pt.clone(),
1788                })
1789                .into(),
1790            ],
1791        }]);
1792        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1793        renderer.resource_describe(&detail, &meta).unwrap();
1794
1795        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1796        let val = &parsed["data"]["values"][0]["values"][0];
1797        assert_eq!(val["value_type"], "date");
1798        assert_eq!(val["calendar"], "GREGORIAN");
1799        assert_eq!(val["start"]["year"], 1489);
1800        assert_eq!(val["start"]["era"], "CE");
1801        assert!(val["start"]["month"].is_null());
1802        assert!(val["start"]["day"].is_null());
1803    }
1804
1805    #[test]
1806    fn resource_describe_json_comment_present_when_set() {
1807        // A value with a comment carries a "comment" key in the value object.
1808        let out = SharedBuf::new();
1809        let mut renderer = JsonRenderer::with_writer(out.clone());
1810        let mut detail = make_resource_detail_no_values();
1811        detail.values = Some(vec![FieldValues {
1812            name: "hasTranscription".into(),
1813            label: None,
1814            values: vec![Value {
1815                content: ValueContent::Text("some transcription".into()),
1816                comment: Some("reading uncertain".into()),
1817            }],
1818        }]);
1819        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1820        renderer.resource_describe(&detail, &meta).unwrap();
1821
1822        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1823        let val = &parsed["data"]["values"][0]["values"][0];
1824        assert_eq!(val["value_type"], "text");
1825        assert_eq!(val["text"], "some transcription");
1826        assert_eq!(val["comment"], "reading uncertain");
1827    }
1828
1829    #[test]
1830    fn resource_describe_json_comment_absent_when_none() {
1831        // A value with no comment must NOT carry a "comment" key at all (omit, not null).
1832        let out = SharedBuf::new();
1833        let mut renderer = JsonRenderer::with_writer(out.clone());
1834        let mut detail = make_resource_detail_no_values();
1835        detail.values = Some(vec![FieldValues {
1836            name: "hasTranscription".into(),
1837            label: None,
1838            values: vec![ValueContent::Text("plain transcription".into()).into()],
1839        }]);
1840        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1841        renderer.resource_describe(&detail, &meta).unwrap();
1842
1843        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1844        let val = &parsed["data"]["values"][0]["values"][0];
1845        assert!(
1846            !val.as_object().unwrap().contains_key("comment"),
1847            "comment key must be absent when comment is None; got: {val}"
1848        );
1849    }
1850}