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                    "status": item.status.as_str(),
386                    "data_models": item.data_models,
387                })
388            })
389            .collect();
390
391        let obj = json!({
392            "_meta": meta_block(meta, 0),
393            "data": data,
394        });
395        writeln!(
396            self.out,
397            "{}",
398            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
399        )?;
400        Ok(())
401    }
402
403    fn project_describe(&mut self, project: &ProjectDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
404        // `data` is a single object (dsp-cli/ADR-0003). Deterministic key order via `json!`
405        // (preserve_order feature ensures insertion order).
406        let description: Vec<serde_json::Value> = project
407            .description
408            .iter()
409            .map(|d| {
410                json!({
411                    "value": d.value,
412                    "language": d.language,
413                })
414            })
415            .collect();
416
417        let data_models: Vec<serde_json::Value> = project
418            .data_models
419            .iter()
420            .map(|dm| {
421                json!({
422                    "name": dm.name,
423                    "iri": dm.iri,
424                })
425            })
426            .collect();
427
428        let obj = json!({
429            "_meta": meta_block(meta, 0),
430            "data": {
431                "iri": project.iri,
432                "shortcode": project.shortcode,
433                "shortname": project.shortname,
434                "longname": project.longname,
435                "status": project.status.as_str(),
436                "description": description,
437                "keywords": project.keywords,
438                "data_models": data_models,
439            },
440        });
441        writeln!(
442            self.out,
443            "{}",
444            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
445        )?;
446        Ok(())
447    }
448
449    fn data_model_describe(&mut self, detail: &DataModelDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
450        // dsp-cli/ADR-0003 single-object envelope. `last_modified` is the full RFC3339
451        // string (lossless). `resource_types` is an array of per-resource-type
452        // objects (name, iri, label).
453        let resource_types: Vec<serde_json::Value> = detail
454            .resource_types
455            .iter()
456            .map(|rt| {
457                json!({
458                    "name": rt.name,
459                    "iri": rt.iri,
460                    "label": rt.label,
461                })
462            })
463            .collect();
464
465        let obj = json!({
466            "_meta": meta_block(meta, 0),
467            "data": {
468                "name": detail.name,
469                "iri": detail.iri,
470                "label": detail.label,
471                "last_modified": detail.last_modified,
472                "resource_types": resource_types,
473            },
474        });
475        writeln!(
476            self.out,
477            "{}",
478            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
479        )?;
480        Ok(())
481    }
482
483    fn data_models(&mut self, view: &DataModelListView, meta: &MetaContext) -> Result<(), Diagnostic> {
484        // Build data array — per-item key order via `json!` insertion order
485        // (preserve_order feature on serde_json, per dsp-cli/ADR-0003).
486        // label None → JSON null; last_modified None → JSON null; is_builtin → bool.
487        let data: Vec<serde_json::Value> = view
488            .items
489            .iter()
490            .map(|item| {
491                json!({
492                    "name": item.name,
493                    "iri": item.iri,
494                    "label": item.label,
495                    "last_modified": item.last_modified,
496                    "is_builtin": item.is_builtin,
497                })
498            })
499            .collect();
500
501        let obj = json!({
502            "_meta": meta_block(meta, 0),
503            "data": data,
504        });
505        writeln!(
506            self.out,
507            "{}",
508            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
509        )?;
510        Ok(())
511    }
512
513    fn resource_types(&mut self, view: &ResourceTypeListView, meta: &MetaContext) -> Result<(), Diagnostic> {
514        // Build data array — per-item key order via `json!` insertion order
515        // (preserve_order feature on serde_json, per dsp-cli/ADR-0003).
516        // label None → JSON null; is_builtin → bool.
517        //
518        // `count` (plan 030) is DELIBERATELY omitted (not emitted as `null`)
519        // when the item carries no count — unlike `label`'s always-present
520        // null, this keeps `--count`-less output (the only case exercised by
521        // today's fixtures/snapshots, since no caller sets `count` yet) byte-
522        // identical to pre-030 output. See design plan 030-resource-type-count in the
523        // dsp-incubator archive.
524        let data: Vec<serde_json::Value> = view
525            .items
526            .iter()
527            .map(|item| {
528                let mut obj = json!({
529                    "name": item.name,
530                    "iri": item.iri,
531                    "label": item.label,
532                    "is_builtin": item.is_builtin,
533                });
534                if let Some(count) = item.count {
535                    obj["count"] = serde_json::Value::from(count);
536                }
537                obj
538            })
539            .collect();
540
541        // D3-style: add `note` to _meta when count_caveat is Some (plan 030).
542        let mut meta_obj = meta_block(meta, 0);
543        if let Some(ref cc) = meta.count_caveat {
544            meta_obj["note"] = serde_json::Value::from(cc.as_str());
545        }
546
547        let obj = json!({
548            "_meta": meta_obj,
549            "data": data,
550        });
551        writeln!(
552            self.out,
553            "{}",
554            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
555        )?;
556        Ok(())
557    }
558
559    fn resource_type_describe(
560        &mut self,
561        detail: &crate::model::ResourceTypeDetail,
562        meta: &MetaContext,
563    ) -> Result<(), Diagnostic> {
564        // dsp-cli/ADR-0003 single-object envelope. `_meta` first, `data` is the resource-type
565        // object. Fields array carries one object per field (name, iri, label,
566        // value_type, link_target, cardinality, is_builtin, data_model).
567        let fields: Vec<serde_json::Value> = detail
568            .fields
569            .iter()
570            .map(|f| {
571                json!({
572                    "name": f.name,
573                    "iri": f.iri,
574                    "label": f.label,
575                    "value_type": f.value_type.to_string(),
576                    "link_target": f.link_target,
577                    "cardinality": f.cardinality.to_string(),
578                    "is_builtin": f.is_builtin,
579                    "data_model": f.data_model,
580                })
581            })
582            .collect();
583
584        // D3-style: add `note` to _meta when count_caveat is Some (plan 030).
585        let mut meta_obj = meta_block(meta, 0);
586        if let Some(ref cc) = meta.count_caveat {
587            meta_obj["note"] = serde_json::Value::from(cc.as_str());
588        }
589
590        // `count` (plan 030) is DELIBERATELY omitted (not emitted as `null`)
591        // when `detail.count` is `None` — keeps `--count`-less output (the
592        // only case exercised by today's fixtures/snapshots) byte-identical
593        // to pre-030 output. See design plan 030-resource-type-count in the dsp-incubator
594        // archive.
595        let mut data = json!({
596            "name": detail.name,
597            "iri": detail.iri,
598            "label": detail.label,
599            "data_model": detail.data_model,
600            "representation": detail.representation.as_ref().map(|r| r.to_string()),
601            "super_types": detail.super_types,
602            "fields": fields,
603        });
604        if let Some(count) = detail.count {
605            data["count"] = serde_json::Value::from(count);
606        }
607
608        let obj = json!({
609            "_meta": meta_obj,
610            "data": data,
611        });
612        writeln!(
613            self.out,
614            "{}",
615            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
616        )?;
617        Ok(())
618    }
619
620    fn data_model_structure(&mut self, structure: &DataModelStructure, meta: &MetaContext) -> Result<(), Diagnostic> {
621        // dsp-cli/ADR-0003 flat-array envelope. Each element carries all 5 keys (none omitted).
622        // Optional values are emitted as JSON null (matching resource_type_describe lines
623        // 476-485 which render None Options as null — never skip_serializing_if).
624        let data: Vec<serde_json::Value> = structure
625            .relations
626            .iter()
627            .map(|r| {
628                json!({
629                    "source": r.source,
630                    "target": r.target,
631                    "kind": r.kind.to_string(),
632                    "field": r.field,
633                    "target_data_model": r.target_data_model,
634                })
635            })
636            .collect();
637
638        let obj = json!({
639            "_meta": meta_block(meta, 0),
640            "data": data,
641        });
642        writeln!(
643            self.out,
644            "{}",
645            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
646        )?;
647        Ok(())
648    }
649
650    fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
651        // Build data array.
652        let data: Vec<serde_json::Value> = view
653            .items
654            .iter()
655            .map(|item| {
656                json!({
657                    "label": item.label,
658                    "iri": item.iri,
659                    "ark_url": item.ark_url,
660                    "creation_date": item.creation_date,
661                    "last_modified": item.last_modified,
662                    "resource_type": item.resource_type,
663                })
664            })
665            .collect();
666
667        // Build _meta pagination keys (D5 — two asymmetric shapes by mode).
668        let mut meta_obj = meta_block(meta, 0);
669        match &view.pagination {
670            ResourceListPagination::SinglePage { page, may_have_more } => {
671                meta_obj["page"] = serde_json::Value::from(*page);
672                meta_obj["may_have_more_results"] = serde_json::Value::from(*may_have_more);
673            }
674            ResourceListPagination::AllPages { pages_fetched } => {
675                meta_obj["pages_fetched"] = serde_json::Value::from(*pages_fetched);
676                // AllPages always exits on may_have_more_results = false (loop invariant).
677                meta_obj["may_have_more_results"] = serde_json::Value::from(false);
678            }
679        }
680
681        // D3: add `note` to _meta when filter_warning is Some.
682        if let Some(ref fw) = meta.filter_warning {
683            meta_obj["note"] = serde_json::Value::from(fw.as_str());
684        }
685
686        let obj = json!({
687            "_meta": meta_obj,
688            "data": data,
689        });
690        writeln!(
691            self.out,
692            "{}",
693            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
694        )?;
695        Ok(())
696    }
697
698    fn resource_describe(&mut self, detail: &ResourceDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
699        // dsp-cli/ADR-0003 single-object envelope. `data` is an object (not array).
700        // Keys in deterministic order; `None` → JSON null.
701        // D3: add `note` to _meta when filter_warning is Some.
702        let mut meta_obj = meta_block(meta, 0);
703        if let Some(ref fw) = meta.filter_warning {
704            meta_obj["note"] = serde_json::Value::from(fw.as_str());
705        }
706
707        // Build data object with explicit key ordering (preserve_order, dsp-cli/ADR-0003).
708        let mut data = serde_json::Map::new();
709        data.insert("label".into(), serde_json::Value::String(detail.label.clone()));
710        data.insert("iri".into(), serde_json::Value::String(detail.iri.clone()));
711        data.insert("resource_type".into(), serde_json::Value::String(detail.resource_type.clone()));
712        data.insert(
713            "ark_url".into(),
714            detail
715                .ark_url
716                .as_ref()
717                .map_or(serde_json::Value::Null, |s| serde_json::Value::String(s.clone())),
718        );
719        data.insert(
720            "creation_date".into(),
721            detail
722                .creation_date
723                .as_ref()
724                .map_or(serde_json::Value::Null, |s| serde_json::Value::String(s.clone())),
725        );
726        data.insert(
727            "last_modified".into(),
728            detail
729                .last_modified
730                .as_ref()
731                .map_or(serde_json::Value::Null, |s| serde_json::Value::String(s.clone())),
732        );
733        data.insert(
734            "attached_project".into(),
735            detail
736                .attached_project
737                .as_ref()
738                .map_or(serde_json::Value::Null, |s| serde_json::Value::String(s.clone())),
739        );
740        data.insert(
741            "owner".into(),
742            detail
743                .owner
744                .as_ref()
745                .map_or(serde_json::Value::Null, |s| serde_json::Value::String(s.clone())),
746        );
747        data.insert(
748            "visibility".into(),
749            detail
750                .visibility
751                .as_ref()
752                .map_or(serde_json::Value::Null, |v| serde_json::Value::String(v.as_str().into())),
753        );
754        data.insert(
755            "your_access".into(),
756            detail
757                .your_access
758                .as_ref()
759                .map_or(serde_json::Value::Null, |a| serde_json::Value::String(a.as_str().into())),
760        );
761        // `values` key is present only when --values was set (detail.values is Some).
762        // Absent (not null) when None — preserves 8b envelope byte-for-byte.
763        if let Some(ref fields) = detail.values {
764            let values_arr: Vec<serde_json::Value> = fields
765                .iter()
766                .map(|fv| {
767                    let value_objs: Vec<serde_json::Value> = fv
768                        .values
769                        .iter()
770                        .map(|v| {
771                            let mut obj = value_content_to_json(&v.content);
772                            if let (Some(c), serde_json::Value::Object(m)) = (&v.comment, &mut obj) {
773                                m.insert("comment".into(), serde_json::Value::String(c.clone()));
774                            }
775                            obj
776                        })
777                        .collect();
778                    let mut fg = serde_json::Map::new();
779                    fg.insert("field".into(), serde_json::Value::String(fv.name.clone()));
780                    let fl_val = match &fv.label {
781                        Some(s) => serde_json::Value::String(s.clone()),
782                        None => serde_json::Value::Null,
783                    };
784                    fg.insert("field_label".into(), fl_val);
785                    fg.insert("values".into(), serde_json::Value::Array(value_objs));
786                    serde_json::Value::Object(fg)
787                })
788                .collect();
789            data.insert("values".into(), serde_json::Value::Array(values_arr));
790        }
791
792        let obj = json!({
793            "_meta": meta_obj,
794            "data": serde_json::Value::Object(data),
795        });
796        writeln!(
797            self.out,
798            "{}",
799            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
800        )?;
801        Ok(())
802    }
803
804    fn vocabularies(&mut self, view: &VocabularyListView, meta: &MetaContext) -> Result<(), Diagnostic> {
805        // Build data array. `labels`/`comments` are the verbatim, lossless
806        // `{value, language}` arrays (json stays lossless — no per-language
807        // column collapsing, unlike tabular). `nodes`/`depth` (plan 034) are
808        // DELIBERATELY omitted (not emitted as null) when the item carries no
809        // count — mirrors `resource_types`'s `count` omission convention.
810        let data: Vec<serde_json::Value> = view
811            .items
812            .iter()
813            .map(|item| {
814                let labels = localized_text_array(&item.header.labels);
815                let comments = localized_text_array(&item.header.comments);
816                let mut obj = json!({
817                    "name": item.header.name,
818                    "iri": item.header.iri,
819                    "labels": labels,
820                    "comments": comments,
821                });
822                if let Some(n) = item.node_count {
823                    obj["nodes"] = serde_json::Value::from(n);
824                }
825                if let Some(d) = item.depth {
826                    obj["depth"] = serde_json::Value::from(d);
827                }
828                obj
829            })
830            .collect();
831
832        // D3-style: add `note` to _meta when count_cost is Some (plan 034;
833        // mirrors the count_caveat assignment above for `resource_types`).
834        let mut meta_obj = meta_block(meta, 0);
835        if let Some(ref cc) = meta.count_cost {
836            meta_obj["note"] = serde_json::Value::from(cc.as_str());
837        }
838
839        let obj = json!({
840            "_meta": meta_obj,
841            "data": data,
842        });
843        writeln!(
844            self.out,
845            "{}",
846            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
847        )?;
848        Ok(())
849    }
850
851    fn vocabulary_describe(&mut self, detail: &VocabularyDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
852        // dsp-cli/ADR-0003 single-object envelope. `nodes`/`depth` are ALWAYS present
853        // here (plain `usize` on `VocabularyDetail`, unlike `list`'s Option) —
854        // the omit-when-absent rule above does not apply.
855        //
856        // Per-node array key: named `"children"` (not `"nodes"`, which would
857        // collide with the top-level node-count key `data.nodes`) — chosen to
858        // match the model's own `VocabularyTree.children`/`VocabularyNode.children`
859        // naming: json's `data.children` IS a nested tree, mirroring the
860        // model shape one-to-one (plan 034 review fix — json is the lossless
861        // path, so it renders the tree AS a tree rather than flattening it
862        // like the tabular formats do). `path`/`depth`/`parent_iri` are
863        // dropped here (structural/derivable from nesting); `number` and
864        // `position` are kept.
865        fn nested_to_json(node: &NestedVocabularyNode<'_>) -> serde_json::Value {
866            let h = node.header;
867            json!({
868                "node_iri": h.iri,
869                "number": node.number,
870                "name": h.name,
871                "labels": localized_text_array(&h.labels),
872                "comments": localized_text_array(&h.comments),
873                "position": node.position,
874                "children": node.children.iter().map(nested_to_json).collect::<Vec<_>>(),
875            })
876        }
877
878        let root = &detail.tree.root;
879        let children: Vec<serde_json::Value> =
880            nest_vocabulary_detail(detail).iter().map(nested_to_json).collect::<Vec<_>>();
881
882        let mut data = json!({
883            "name": root.name,
884            "iri": root.iri,
885            "labels": localized_text_array(&root.labels),
886            "comments": localized_text_array(&root.comments),
887            "nodes": detail.node_count,
888            "depth": detail.depth,
889            "children": children,
890        });
891        // `requested_node`/`subtree_of`: omitted (not null) when not
892        // applicable — there is no separate boolean, `subtree_of` being
893        // present IS the `--subtree` flag.
894        if let Some(ref requested) = detail.tree.requested_node {
895            data["requested_node"] = serde_json::Value::from(requested.as_str());
896        }
897        if let Some(ref subtree_of) = detail.subtree_of {
898            data["subtree_of"] = serde_json::Value::from(subtree_of.as_str());
899        }
900
901        let obj = json!({
902            "_meta": meta_block(meta, 0),
903            "data": data,
904        });
905        writeln!(
906            self.out,
907            "{}",
908            serde_json::to_string(&obj).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?
909        )?;
910        Ok(())
911    }
912}
913
914#[cfg(test)]
915mod tests {
916    use super::*;
917    use crate::model::{
918        DataModel, DataModelDetail, DataModelSummary, Project, ProjectDescription, ProjectDetail, ProjectStatus,
919        ResourceType, ResourceTypeSummary,
920    };
921    use crate::render::test_support::{SharedBuf, make_meta};
922    use crate::render::{DataModelListView, ResourceTypeListView};
923
924    #[test]
925    fn meta_block_full_context_has_all_three_keys() {
926        // Non-empty server_label/auth_state (the shape every success path uses)
927        // → server, auth, and exit_code all present.
928        let meta = MetaContext {
929            server_label: "https://api.test.dasch.swiss".into(),
930            auth_state: "anonymous".into(),
931            filter_warning: None,
932            count_caveat: None,
933            count_cost: None,
934        };
935        let block = meta_block(&meta, 1);
936        let obj = block.as_object().unwrap();
937        assert_eq!(obj["server"], "https://api.test.dasch.swiss");
938        assert_eq!(obj["auth"], "anonymous");
939        assert_eq!(obj["exit_code"], 1);
940        assert_eq!(obj.len(), 3, "expected exactly three keys; got: {block}");
941    }
942
943    #[test]
944    fn meta_block_empty_server_and_auth_omits_both_keys() {
945        // Empty server_label/auth_state (the top-level-error shape, plan 032 D3)
946        // → only exit_code is present; server/auth are absent (not null).
947        let meta = MetaContext {
948            server_label: String::new(),
949            auth_state: String::new(),
950            filter_warning: None,
951            count_caveat: None,
952            count_cost: None,
953        };
954        let block = meta_block(&meta, 2);
955        let obj = block.as_object().unwrap();
956        assert!(
957            !obj.contains_key("server"),
958            "server key must be absent when server_label is empty; got: {block}"
959        );
960        assert!(
961            !obj.contains_key("auth"),
962            "auth key must be absent when auth_state is empty; got: {block}"
963        );
964        assert_eq!(obj["exit_code"], 2);
965        assert_eq!(obj.len(), 1, "expected exactly one key; got: {block}");
966    }
967
968    #[test]
969    fn projects_json_output() {
970        let out = SharedBuf::new();
971        let mut renderer = JsonRenderer::with_writer(out.clone());
972        let items = vec![
973            Project {
974                iri: "http://rdfh.ch/projects/0001".into(),
975                shortcode: "0001".into(),
976                shortname: "anything".into(),
977                longname: Some("Anything Project".into()),
978                status: ProjectStatus::Active,
979                data_models: 2,
980            },
981            Project {
982                iri: "http://rdfh.ch/projects/0002".into(),
983                shortcode: "0002".into(),
984                shortname: "images".into(),
985                longname: None,
986                status: ProjectStatus::Inactive,
987                data_models: 0,
988            },
989        ];
990        let view = ProjectListView { items, total: 2, filter: None };
991        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
992        renderer.projects(&view, &meta).unwrap();
993
994        let s = out.string();
995        let parsed: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
996
997        // _meta present
998        assert_eq!(parsed["_meta"]["auth"], "anonymous");
999        assert_eq!(parsed["_meta"]["server"], "https://api.test.dasch.swiss");
1000        assert_eq!(parsed["_meta"]["exit_code"], 0);
1001
1002        // data is array
1003        let data = parsed["data"].as_array().unwrap();
1004        assert_eq!(data.len(), 2);
1005
1006        // first item
1007        assert_eq!(data[0]["shortcode"], "0001");
1008        assert_eq!(data[0]["shortname"], "anything");
1009        assert_eq!(data[0]["longname"], "Anything Project");
1010        assert_eq!(data[0]["status"], "active");
1011        assert_eq!(data[0]["data_models"], 2);
1012        assert_eq!(data[0]["iri"], "http://rdfh.ch/projects/0001");
1013
1014        // second item — longname None → null
1015        assert_eq!(data[1]["shortcode"], "0002");
1016        assert!(data[1]["longname"].is_null());
1017        assert_eq!(data[1]["status"], "inactive");
1018        assert_eq!(data[1]["data_models"], 0);
1019    }
1020
1021    #[test]
1022    fn projects_json_empty_data_array() {
1023        let out = SharedBuf::new();
1024        let mut renderer = JsonRenderer::with_writer(out.clone());
1025        let view = ProjectListView { items: vec![], total: 0, filter: None };
1026        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1027        renderer.projects(&view, &meta).unwrap();
1028
1029        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1030        assert!(parsed["data"].as_array().unwrap().is_empty());
1031    }
1032
1033    /// Assert that `diagnostic_kind` returns the correct stable string for every
1034    /// `Diagnostic` variant. This test is intentionally exhaustive: adding a new
1035    /// variant without a corresponding arm in `diagnostic_kind` will cause a
1036    /// compiler warning (non-exhaustive match) at the match site, but this test
1037    /// ensures the mapping is also exercised at the call level so the kind string
1038    /// is verified, not just the pattern.
1039    #[test]
1040    fn diagnostic_kind_covers_all_variants() {
1041        let cases: &[(&Diagnostic, &str)] = &[
1042            (&Diagnostic::Usage("x".into()), "usage"),
1043            (&Diagnostic::AuthRequired("x".into()), "auth_required"),
1044            (&Diagnostic::NotFound("x".into()), "not_found"),
1045            (&Diagnostic::ServerError("x".into()), "server_error"),
1046            (&Diagnostic::Network("x".into()), "network"),
1047            (&Diagnostic::Conflict("x".into()), "conflict"),
1048            (&Diagnostic::Io("x".into()), "io"),
1049            (&Diagnostic::Internal("x".into()), "internal"),
1050            (&Diagnostic::NotImplemented("x".into()), "internal"),
1051        ];
1052        for (diag, expected_kind) in cases {
1053            assert_eq!(diagnostic_kind(diag), *expected_kind, "unexpected kind for {diag:?}");
1054        }
1055    }
1056
1057    #[test]
1058    fn conflict_kind_is_conflict() {
1059        let d = Diagnostic::Conflict("dump already in progress".into());
1060        assert_eq!(diagnostic_kind(&d), "conflict");
1061    }
1062
1063    #[test]
1064    fn io_kind_is_io() {
1065        let d = Diagnostic::Io("failed to write /tmp/0001.zip: permission denied".into());
1066        assert_eq!(diagnostic_kind(&d), "io");
1067    }
1068
1069    /// Pins the `kind` / `_meta.exit_code` mapping for a NON-usage `Diagnostic`
1070    /// via `JsonRenderer::diagnostic` (plan 032's Test plan). The binary-level
1071    /// tests in `tests/cli.rs` only exercise usage errors (exit code 2, no
1072    /// server needed); this test covers a Runtime-category kind (exit code 1)
1073    /// generically, without a server, so a hard-to-trigger non-network exit-3
1074    /// case isn't required.
1075    #[test]
1076    fn diagnostic_not_found_json_output() {
1077        let out = SharedBuf::new();
1078        let mut renderer = JsonRenderer::with_writer(out.clone());
1079        let diag = Diagnostic::NotFound("resource http://rdfh.ch/0001/xyz not found".into());
1080        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1081        renderer.diagnostic(&diag, &meta).unwrap();
1082
1083        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1084        assert_eq!(parsed["error"]["kind"], "not_found");
1085        assert_eq!(parsed["_meta"]["exit_code"], 1);
1086        let message = parsed["error"]["message"].as_str().expect("error.message must be a string");
1087        assert!(!message.is_empty(), "error.message must be non-empty");
1088        assert_eq!(message, diag.to_string());
1089    }
1090
1091    fn make_beol_detail() -> ProjectDetail {
1092        // Four data-models passed in already-sorted order (client layer sorts;
1093        // renderer passes them through as-is). Having all four here guards against
1094        // a renderer that truncates or reorders the slice.
1095        ProjectDetail {
1096            iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
1097            shortcode: "0801".into(),
1098            shortname: "beol".into(),
1099            longname: Some("Bernoulli-Euler Online".into()),
1100            status: ProjectStatus::Active,
1101            description: vec![ProjectDescription {
1102                value: "<b>BEOL</b> — early modern mathematics.".into(),
1103                language: Some("en".into()),
1104            }],
1105            keywords: vec!["Bernoulli".into(), "Euler".into(), "Mathematics".into()],
1106            data_models: vec![
1107                DataModelSummary {
1108                    name: "beol".into(),
1109                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1110                },
1111                DataModelSummary {
1112                    name: "biblio".into(),
1113                    iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1114                },
1115                DataModelSummary {
1116                    name: "leibniz".into(),
1117                    iri: "http://api.dasch.swiss/ontology/0801/leibniz/v2".into(),
1118                },
1119                DataModelSummary {
1120                    name: "newton".into(),
1121                    iri: "http://api.dasch.swiss/ontology/0801/newton/v2".into(),
1122                },
1123            ],
1124        }
1125    }
1126
1127    #[test]
1128    fn project_describe_json_full() {
1129        let out = SharedBuf::new();
1130        let mut renderer = JsonRenderer::with_writer(out.clone());
1131        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1132        renderer.project_describe(&make_beol_detail(), &meta).unwrap();
1133
1134        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1135
1136        // _meta present
1137        assert_eq!(parsed["_meta"]["auth"], "anonymous");
1138        assert_eq!(parsed["_meta"]["exit_code"], 0);
1139
1140        // data is a single object
1141        let data = &parsed["data"];
1142        assert!(data.is_object());
1143        assert_eq!(data["iri"], "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF");
1144        assert_eq!(data["shortcode"], "0801");
1145        assert_eq!(data["shortname"], "beol");
1146        assert_eq!(data["longname"], "Bernoulli-Euler Online");
1147        assert_eq!(data["status"], "active");
1148
1149        // description array
1150        let desc = data["description"].as_array().unwrap();
1151        assert_eq!(desc.len(), 1);
1152        assert_eq!(desc[0]["value"], "<b>BEOL</b> — early modern mathematics.");
1153        assert_eq!(desc[0]["language"], "en");
1154
1155        // keywords array
1156        let kws = data["keywords"].as_array().unwrap();
1157        assert_eq!(kws.len(), 3);
1158        assert_eq!(kws[0], "Bernoulli");
1159
1160        // data_models array — four entries in sorted order (beol, biblio, leibniz, newton).
1161        // Guards against a renderer that truncates or reorders the slice.
1162        let dms = data["data_models"].as_array().unwrap();
1163        assert_eq!(dms.len(), 4, "all four data-models must be rendered");
1164        assert_eq!(dms[0]["name"], "beol");
1165        assert_eq!(dms[0]["iri"], "http://api.dasch.swiss/ontology/0801/beol/v2");
1166        assert_eq!(dms[1]["name"], "biblio");
1167        assert_eq!(dms[2]["name"], "leibniz");
1168        assert_eq!(dms[3]["name"], "newton");
1169    }
1170
1171    #[test]
1172    fn project_describe_json_no_longname() {
1173        let out = SharedBuf::new();
1174        let mut renderer = JsonRenderer::with_writer(out.clone());
1175        let mut detail = make_beol_detail();
1176        detail.longname = None;
1177        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1178        renderer.project_describe(&detail, &meta).unwrap();
1179
1180        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1181        // longname None → JSON null
1182        assert!(parsed["data"]["longname"].is_null());
1183    }
1184
1185    #[test]
1186    fn project_describe_json_empty_fields() {
1187        let out = SharedBuf::new();
1188        let mut renderer = JsonRenderer::with_writer(out.clone());
1189        let detail = ProjectDetail {
1190            iri: "http://rdfh.ch/projects/0000".into(),
1191            shortcode: "0000".into(),
1192            shortname: "minimal".into(),
1193            longname: None,
1194            status: ProjectStatus::Inactive,
1195            description: vec![],
1196            keywords: vec![],
1197            data_models: vec![],
1198        };
1199        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1200        renderer.project_describe(&detail, &meta).unwrap();
1201
1202        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1203        let data = &parsed["data"];
1204        assert_eq!(data["status"], "inactive");
1205        assert!(data["description"].as_array().unwrap().is_empty());
1206        assert!(data["keywords"].as_array().unwrap().is_empty());
1207        assert!(data["data_models"].as_array().unwrap().is_empty());
1208    }
1209
1210    fn make_data_model_fixture() -> Vec<DataModel> {
1211        vec![
1212            DataModel {
1213                name: "beol".into(),
1214                iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1215                label: Some("The BEOL data-model".into()),
1216                last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1217                is_builtin: false,
1218            },
1219            DataModel {
1220                name: "biblio".into(),
1221                iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1222                label: None,
1223                last_modified: None,
1224                is_builtin: false,
1225            },
1226            DataModel {
1227                name: "knora-api".into(),
1228                iri: "http://api.knora.org/ontology/knora-api/v2".into(),
1229                label: None,
1230                last_modified: None,
1231                is_builtin: true,
1232            },
1233        ]
1234    }
1235
1236    #[test]
1237    fn data_models_json_output() {
1238        let out = SharedBuf::new();
1239        let mut renderer = JsonRenderer::with_writer(out.clone());
1240        let view = DataModelListView { items: make_data_model_fixture(), total: 3, filter: None };
1241        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1242        renderer.data_models(&view, &meta).unwrap();
1243
1244        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1245
1246        // _meta present
1247        assert_eq!(parsed["_meta"]["auth"], "anonymous");
1248        assert_eq!(parsed["_meta"]["server"], "https://api.test.dasch.swiss");
1249        assert_eq!(parsed["_meta"]["exit_code"], 0);
1250
1251        // data is an array
1252        let data = parsed["data"].as_array().unwrap();
1253        assert_eq!(data.len(), 3);
1254
1255        // first item: has label and last_modified, not builtin
1256        assert_eq!(data[0]["name"], "beol");
1257        assert_eq!(data[0]["iri"], "http://api.dasch.swiss/ontology/0801/beol/v2");
1258        assert_eq!(data[0]["label"], "The BEOL data-model");
1259        assert_eq!(data[0]["last_modified"], "2024-05-27T13:43:26.233048Z");
1260        assert_eq!(data[0]["is_builtin"], false);
1261
1262        // second item: label None → null, last_modified None → null
1263        assert_eq!(data[1]["name"], "biblio");
1264        assert!(data[1]["label"].is_null());
1265        assert!(data[1]["last_modified"].is_null());
1266        assert_eq!(data[1]["is_builtin"], false);
1267
1268        // third item: builtin, both null
1269        assert_eq!(data[2]["name"], "knora-api");
1270        assert!(data[2]["label"].is_null());
1271        assert!(data[2]["last_modified"].is_null());
1272        assert_eq!(data[2]["is_builtin"], true);
1273    }
1274
1275    #[test]
1276    fn data_models_json_empty_data_array() {
1277        let out = SharedBuf::new();
1278        let mut renderer = JsonRenderer::with_writer(out.clone());
1279        let view = DataModelListView { items: vec![], total: 0, filter: None };
1280        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1281        renderer.data_models(&view, &meta).unwrap();
1282
1283        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1284        assert!(parsed["data"].as_array().unwrap().is_empty());
1285    }
1286
1287    // ── data_model_describe JSON tests ────────────────────────────────────────
1288
1289    fn make_beol_dm_detail() -> DataModelDetail {
1290        DataModelDetail {
1291            name: "beol".into(),
1292            iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1293            label: Some("The BEOL data-model".into()),
1294            last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1295            resource_types: vec![
1296                ResourceTypeSummary {
1297                    name: "Archive".into(),
1298                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1299                    label: Some("Archive".into()),
1300                },
1301                ResourceTypeSummary {
1302                    name: "basicLetter".into(),
1303                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
1304                    label: None,
1305                },
1306                ResourceTypeSummary {
1307                    name: "letter".into(),
1308                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1309                    label: Some("Letter".into()),
1310                },
1311            ],
1312        }
1313    }
1314
1315    #[test]
1316    fn data_model_describe_json_full() {
1317        let out = SharedBuf::new();
1318        let mut renderer = JsonRenderer::with_writer(out.clone());
1319        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1320        renderer.data_model_describe(&make_beol_dm_detail(), &meta).unwrap();
1321
1322        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1323
1324        // _meta present
1325        assert_eq!(parsed["_meta"]["auth"], "anonymous");
1326        assert_eq!(parsed["_meta"]["server"], "https://api.test.dasch.swiss");
1327        assert_eq!(parsed["_meta"]["exit_code"], 0);
1328
1329        // data is a single object (dsp-cli/ADR-0003)
1330        let data = &parsed["data"];
1331        assert!(data.is_object());
1332        assert_eq!(data["name"], "beol");
1333        assert_eq!(data["iri"], "http://api.dasch.swiss/ontology/0801/beol/v2");
1334        assert_eq!(data["label"], "The BEOL data-model");
1335        // last_modified is the full RFC3339 string (lossless)
1336        assert_eq!(data["last_modified"], "2024-05-27T13:43:26.233048Z");
1337
1338        // resource_types array
1339        let rts = data["resource_types"].as_array().unwrap();
1340        assert_eq!(rts.len(), 3);
1341        assert_eq!(rts[0]["name"], "Archive");
1342        assert_eq!(rts[0]["iri"], "http://api.dasch.swiss/ontology/0801/beol/v2#Archive");
1343        assert_eq!(rts[0]["label"], "Archive");
1344        // label None → null
1345        assert_eq!(rts[1]["name"], "basicLetter");
1346        assert!(rts[1]["label"].is_null());
1347        assert_eq!(rts[2]["name"], "letter");
1348        assert_eq!(rts[2]["label"], "Letter");
1349    }
1350
1351    #[test]
1352    fn data_model_describe_json_no_label_no_last_modified() {
1353        let out = SharedBuf::new();
1354        let mut renderer = JsonRenderer::with_writer(out.clone());
1355        let detail = DataModelDetail {
1356            name: "minimal".into(),
1357            iri: "http://api.dasch.swiss/ontology/0000/minimal/v2".into(),
1358            label: None,
1359            last_modified: None,
1360            resource_types: vec![],
1361        };
1362        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1363        renderer.data_model_describe(&detail, &meta).unwrap();
1364
1365        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1366        let data = &parsed["data"];
1367        // label None → null
1368        assert!(data["label"].is_null());
1369        // last_modified None → null
1370        assert!(data["last_modified"].is_null());
1371        // resource_types empty array
1372        assert!(data["resource_types"].as_array().unwrap().is_empty());
1373    }
1374
1375    // ── resource_types JSON tests ─────────────────────────────────────────────
1376
1377    fn make_rt_fixture() -> Vec<ResourceType> {
1378        vec![
1379            ResourceType {
1380                name: "Archive".into(),
1381                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1382                label: Some("Archive".into()),
1383                is_builtin: false,
1384                count: None,
1385            },
1386            ResourceType {
1387                name: "letter".into(),
1388                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1389                label: None,
1390                is_builtin: false,
1391                count: None,
1392            },
1393        ]
1394    }
1395
1396    #[test]
1397    fn resource_types_json_output() {
1398        let out = SharedBuf::new();
1399        let mut renderer = JsonRenderer::with_writer(out.clone());
1400        let view = ResourceTypeListView {
1401            items: make_rt_fixture(),
1402            total: 2,
1403            filter: None,
1404            data_model: "beol".into(),
1405        };
1406        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1407        renderer.resource_types(&view, &meta).unwrap();
1408
1409        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1410
1411        // _meta present
1412        assert_eq!(parsed["_meta"]["auth"], "anonymous");
1413        assert_eq!(parsed["_meta"]["server"], "https://api.test.dasch.swiss");
1414        assert_eq!(parsed["_meta"]["exit_code"], 0);
1415
1416        // data is an array
1417        let data = parsed["data"].as_array().unwrap();
1418        assert_eq!(data.len(), 2);
1419
1420        // first item: has label, not builtin
1421        assert_eq!(data[0]["name"], "Archive");
1422        assert_eq!(data[0]["iri"], "http://api.dasch.swiss/ontology/0801/beol/v2#Archive");
1423        assert_eq!(data[0]["label"], "Archive");
1424        assert_eq!(data[0]["is_builtin"], false);
1425
1426        // second item: label None → null
1427        assert_eq!(data[1]["name"], "letter");
1428        assert!(data[1]["label"].is_null());
1429        assert_eq!(data[1]["is_builtin"], false);
1430    }
1431
1432    #[test]
1433    fn resource_types_json_with_builtins() {
1434        let out = SharedBuf::new();
1435        let mut renderer = JsonRenderer::with_writer(out.clone());
1436        let view = ResourceTypeListView {
1437            items: vec![ResourceType {
1438                name: "Region".into(),
1439                iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1440                label: Some("Region".into()),
1441                is_builtin: true,
1442                count: None,
1443            }],
1444            total: 1,
1445            filter: None,
1446            data_model: "beol".into(),
1447        };
1448        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1449        renderer.resource_types(&view, &meta).unwrap();
1450
1451        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1452        let data = parsed["data"].as_array().unwrap();
1453        assert_eq!(data.len(), 1);
1454        assert_eq!(data[0]["name"], "Region");
1455        assert_eq!(data[0]["label"], "Region");
1456        // is_builtin must be true (a bool, not a string)
1457        assert_eq!(data[0]["is_builtin"], true);
1458    }
1459
1460    #[test]
1461    fn resource_types_json_with_filter() {
1462        // filter does not affect JSON output shape; just verify it renders cleanly
1463        let out = SharedBuf::new();
1464        let mut renderer = JsonRenderer::with_writer(out.clone());
1465        let view = ResourceTypeListView {
1466            items: vec![make_rt_fixture().remove(0)], // just Archive
1467            total: 2,
1468            filter: Some("arch".to_string()),
1469            data_model: "beol".into(),
1470        };
1471        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1472        renderer.resource_types(&view, &meta).unwrap();
1473
1474        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1475        let data = parsed["data"].as_array().unwrap();
1476        assert_eq!(data.len(), 1);
1477        assert_eq!(data[0]["name"], "Archive");
1478    }
1479
1480    #[test]
1481    fn resource_types_json_empty_data_array() {
1482        let out = SharedBuf::new();
1483        let mut renderer = JsonRenderer::with_writer(out.clone());
1484        let view = ResourceTypeListView {
1485            items: vec![],
1486            total: 0,
1487            filter: None,
1488            data_model: "beol".into(),
1489        };
1490        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1491        renderer.resource_types(&view, &meta).unwrap();
1492
1493        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1494        assert!(parsed["data"].as_array().unwrap().is_empty());
1495    }
1496
1497    #[test]
1498    fn resource_types_json_with_count_and_caveat() {
1499        // plan 030: `count` key present (numeric) when Some, `_meta.note`
1500        // carries `count_caveat` when Some.
1501        let out = SharedBuf::new();
1502        let mut renderer = JsonRenderer::with_writer(out.clone());
1503        let mut items = make_rt_fixture();
1504        items[0].count = Some(5);
1505        let view = ResourceTypeListView { items, total: 2, filter: None, data_model: "beol".into() };
1506        let meta = crate::render::MetaContext {
1507            server_label: "https://api.test.dasch.swiss".into(),
1508            auth_state: "anonymous".into(),
1509            filter_warning: None,
1510            count_caveat: Some("counts are not permission-filtered".into()),
1511            count_cost: None,
1512        };
1513        renderer.resource_types(&view, &meta).unwrap();
1514
1515        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1516        assert_eq!(parsed["data"][0]["count"], 5);
1517        // Second item has no count → key absent (not null).
1518        assert!(
1519            !parsed["data"][1].as_object().unwrap().contains_key("count"),
1520            "count key must be absent when None; got: {}",
1521            parsed["data"][1]
1522        );
1523        assert_eq!(
1524            parsed["_meta"]["note"], "counts are not permission-filtered",
1525            "note must carry count_caveat"
1526        );
1527    }
1528
1529    #[test]
1530    fn resource_types_json_no_count_no_note() {
1531        // Regression: count_caveat: None (today's only production case) →
1532        // no `note` key, no `count` key on any item.
1533        let out = SharedBuf::new();
1534        let mut renderer = JsonRenderer::with_writer(out.clone());
1535        let view = ResourceTypeListView {
1536            items: make_rt_fixture(),
1537            total: 2,
1538            filter: None,
1539            data_model: "beol".into(),
1540        };
1541        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1542        renderer.resource_types(&view, &meta).unwrap();
1543
1544        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1545        assert!(!parsed["_meta"].as_object().unwrap().contains_key("note"));
1546        assert!(!parsed["data"][0].as_object().unwrap().contains_key("count"));
1547    }
1548
1549    // ── resource_type_describe JSON tests ─────────────────────────────────────
1550
1551    fn make_minimal_rt_detail() -> crate::model::ResourceTypeDetail {
1552        crate::model::ResourceTypeDetail {
1553            name: "manuscript".into(),
1554            iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1555            label: Some("Manuscript".into()),
1556            data_model: "beol".into(),
1557            representation: None,
1558            super_types: vec![],
1559            fields: vec![],
1560            count: None,
1561        }
1562    }
1563
1564    #[test]
1565    fn resource_type_describe_json_with_count_and_caveat() {
1566        let out = SharedBuf::new();
1567        let mut renderer = JsonRenderer::with_writer(out.clone());
1568        let mut detail = make_minimal_rt_detail();
1569        detail.count = Some(99);
1570        let meta = crate::render::MetaContext {
1571            server_label: "https://api.dasch.swiss".into(),
1572            auth_state: "anonymous".into(),
1573            filter_warning: None,
1574            count_caveat: Some("counts exclude deleted resources".into()),
1575            count_cost: None,
1576        };
1577        renderer.resource_type_describe(&detail, &meta).unwrap();
1578
1579        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1580        assert_eq!(parsed["data"]["count"], 99);
1581        assert_eq!(
1582            parsed["_meta"]["note"], "counts exclude deleted resources",
1583            "note must carry count_caveat"
1584        );
1585    }
1586
1587    #[test]
1588    fn resource_type_describe_json_no_count_no_note() {
1589        // Regression: count: None / count_caveat: None (today's only
1590        // production case) → no `count` key, no `note` key.
1591        let out = SharedBuf::new();
1592        let mut renderer = JsonRenderer::with_writer(out.clone());
1593        let meta = make_meta("anonymous", "https://api.dasch.swiss");
1594        renderer.resource_type_describe(&make_minimal_rt_detail(), &meta).unwrap();
1595
1596        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1597        assert!(!parsed["data"].as_object().unwrap().contains_key("count"));
1598        assert!(!parsed["_meta"].as_object().unwrap().contains_key("note"));
1599    }
1600
1601    // ── resource_describe JSON values tests ───────────────────────────────────
1602
1603    use crate::model::resource_type::ValueType;
1604    use crate::model::{
1605        DatePoint, DateValue, FieldValues, FileValue, ResourceAccess, ResourceDetail, ResourceVisibility, Value,
1606        ValueContent,
1607    };
1608
1609    fn make_resource_detail_no_values() -> ResourceDetail {
1610        ResourceDetail {
1611            label: "Test Resource".into(),
1612            iri: "http://rdfh.ch/0803/abc123".into(),
1613            resource_type: "Page".into(),
1614            ark_url: None,
1615            creation_date: None,
1616            last_modified: None,
1617            attached_project: None,
1618            owner: None,
1619            visibility: Some(ResourceVisibility::Public),
1620            your_access: Some(ResourceAccess::View),
1621            values: None,
1622        }
1623    }
1624
1625    #[test]
1626    fn resource_describe_json_no_values_key_absent() {
1627        // When values is None, the "values" key must be ABSENT (not null) in json output.
1628        let out = SharedBuf::new();
1629        let mut renderer = JsonRenderer::with_writer(out.clone());
1630        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1631        renderer.resource_describe(&make_resource_detail_no_values(), &meta).unwrap();
1632
1633        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1634        let data = &parsed["data"];
1635        assert!(
1636            data["values"].is_null() && !data.as_object().unwrap().contains_key("values"),
1637            "values key must be absent when values is None; got data: {data}"
1638        );
1639    }
1640
1641    #[test]
1642    fn resource_describe_json_some_values_array_present() {
1643        // When values is Some, the "values" key must be present in json data.
1644        let out = SharedBuf::new();
1645        let mut renderer = JsonRenderer::with_writer(out.clone());
1646        let mut detail = make_resource_detail_no_values();
1647        detail.values = Some(vec![FieldValues {
1648            name: "hasTitle".into(),
1649            label: Some("Title".into()),
1650            values: vec![ValueContent::Text("Hello".into()).into()],
1651        }]);
1652        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1653        renderer.resource_describe(&detail, &meta).unwrap();
1654
1655        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1656        let data = &parsed["data"];
1657        assert!(
1658            data.as_object().unwrap().contains_key("values"),
1659            "values key must be present when values is Some; got data: {data}"
1660        );
1661        let values = data["values"].as_array().unwrap();
1662        assert_eq!(values.len(), 1);
1663        assert_eq!(values[0]["field"], "hasTitle");
1664        assert_eq!(values[0]["field_label"], "Title");
1665        let val_objs = values[0]["values"].as_array().unwrap();
1666        assert_eq!(val_objs.len(), 1);
1667        assert_eq!(val_objs[0]["value_type"], "text");
1668        assert_eq!(val_objs[0]["text"], "Hello");
1669    }
1670
1671    #[test]
1672    fn resource_describe_json_some_empty_values_array() {
1673        // Some(vec![]) → "values": [] — key present, empty array.
1674        let out = SharedBuf::new();
1675        let mut renderer = JsonRenderer::with_writer(out.clone());
1676        let mut detail = make_resource_detail_no_values();
1677        detail.values = Some(vec![]);
1678        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1679        renderer.resource_describe(&detail, &meta).unwrap();
1680
1681        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1682        let data = &parsed["data"];
1683        assert!(
1684            data.as_object().unwrap().contains_key("values"),
1685            "values key must be present even for empty Some(vec![]);"
1686        );
1687        assert!(data["values"].as_array().unwrap().is_empty());
1688    }
1689
1690    #[test]
1691    fn resource_describe_json_field_label_null_when_none() {
1692        // field_label null when label is None.
1693        let out = SharedBuf::new();
1694        let mut renderer = JsonRenderer::with_writer(out.clone());
1695        let mut detail = make_resource_detail_no_values();
1696        detail.values = Some(vec![FieldValues {
1697            name: "seqnum".into(),
1698            label: None,
1699            values: vec![ValueContent::Integer(42).into()],
1700        }]);
1701        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1702        renderer.resource_describe(&detail, &meta).unwrap();
1703
1704        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1705        let fg = &parsed["data"]["values"][0];
1706        assert!(fg["field_label"].is_null(), "field_label must be null when label is None");
1707        let val = &fg["values"][0];
1708        assert_eq!(val["value_type"], "integer");
1709        assert_eq!(val["value"], 42);
1710    }
1711
1712    #[test]
1713    fn resource_describe_json_link_value() {
1714        let out = SharedBuf::new();
1715        let mut renderer = JsonRenderer::with_writer(out.clone());
1716        let mut detail = make_resource_detail_no_values();
1717        detail.values = Some(vec![FieldValues {
1718            name: "isPartOf".into(),
1719            label: None,
1720            values: vec![
1721                ValueContent::Link {
1722                    target_iri: "http://rdfh.ch/0803/book1".into(),
1723                    target_label: Some("My Book".into()),
1724                }
1725                .into(),
1726                ValueContent::Link {
1727                    target_iri: "http://rdfh.ch/0803/book2".into(),
1728                    target_label: None,
1729                }
1730                .into(),
1731            ],
1732        }]);
1733        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1734        renderer.resource_describe(&detail, &meta).unwrap();
1735
1736        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1737        let vals = &parsed["data"]["values"][0]["values"];
1738        // first: with label
1739        assert_eq!(vals[0]["value_type"], "link");
1740        assert_eq!(vals[0]["target_iri"], "http://rdfh.ch/0803/book1");
1741        assert_eq!(vals[0]["target_label"], "My Book");
1742        // second: no label → null
1743        assert_eq!(vals[1]["value_type"], "link");
1744        assert_eq!(vals[1]["target_iri"], "http://rdfh.ch/0803/book2");
1745        assert!(vals[1]["target_label"].is_null());
1746    }
1747
1748    #[test]
1749    fn resource_describe_json_still_image_value() {
1750        let out = SharedBuf::new();
1751        let mut renderer = JsonRenderer::with_writer(out.clone());
1752        let mut detail = make_resource_detail_no_values();
1753        detail.values = Some(vec![FieldValues {
1754            name: "hasStillImageFileValue".into(),
1755            label: None,
1756            values: vec![
1757                ValueContent::File(FileValue {
1758                    value_type: ValueType::StillImage,
1759                    filename: "image.jp2".into(),
1760                    url: "https://iiif.example.com/image.jp2/full/max/0/default.jpg".into(),
1761                    width: Some(1200),
1762                    height: Some(800),
1763                })
1764                .into(),
1765            ],
1766        }]);
1767        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1768        renderer.resource_describe(&detail, &meta).unwrap();
1769
1770        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1771        let val = &parsed["data"]["values"][0]["values"][0];
1772        assert_eq!(val["value_type"], "still-image");
1773        assert_eq!(val["filename"], "image.jp2");
1774        assert_eq!(val["url"], "https://iiif.example.com/image.jp2/full/max/0/default.jpg");
1775        assert_eq!(val["width"], 1200);
1776        assert_eq!(val["height"], 800);
1777    }
1778
1779    #[test]
1780    fn resource_describe_json_date_value() {
1781        let out = SharedBuf::new();
1782        let mut renderer = JsonRenderer::with_writer(out.clone());
1783        let mut detail = make_resource_detail_no_values();
1784        let pt = DatePoint {
1785            year: Some(1489),
1786            month: None,
1787            day: None,
1788            era: Some("CE".into()),
1789        };
1790        detail.values = Some(vec![FieldValues {
1791            name: "hasDate".into(),
1792            label: None,
1793            values: vec![
1794                ValueContent::Date(DateValue {
1795                    calendar: "GREGORIAN".into(),
1796                    start: pt.clone(),
1797                    end: pt.clone(),
1798                })
1799                .into(),
1800            ],
1801        }]);
1802        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1803        renderer.resource_describe(&detail, &meta).unwrap();
1804
1805        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1806        let val = &parsed["data"]["values"][0]["values"][0];
1807        assert_eq!(val["value_type"], "date");
1808        assert_eq!(val["calendar"], "GREGORIAN");
1809        assert_eq!(val["start"]["year"], 1489);
1810        assert_eq!(val["start"]["era"], "CE");
1811        assert!(val["start"]["month"].is_null());
1812        assert!(val["start"]["day"].is_null());
1813    }
1814
1815    #[test]
1816    fn resource_describe_json_comment_present_when_set() {
1817        // A value with a comment carries a "comment" key in the value object.
1818        let out = SharedBuf::new();
1819        let mut renderer = JsonRenderer::with_writer(out.clone());
1820        let mut detail = make_resource_detail_no_values();
1821        detail.values = Some(vec![FieldValues {
1822            name: "hasTranscription".into(),
1823            label: None,
1824            values: vec![Value {
1825                content: ValueContent::Text("some transcription".into()),
1826                comment: Some("reading uncertain".into()),
1827            }],
1828        }]);
1829        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1830        renderer.resource_describe(&detail, &meta).unwrap();
1831
1832        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1833        let val = &parsed["data"]["values"][0]["values"][0];
1834        assert_eq!(val["value_type"], "text");
1835        assert_eq!(val["text"], "some transcription");
1836        assert_eq!(val["comment"], "reading uncertain");
1837    }
1838
1839    #[test]
1840    fn resource_describe_json_comment_absent_when_none() {
1841        // A value with no comment must NOT carry a "comment" key at all (omit, not null).
1842        let out = SharedBuf::new();
1843        let mut renderer = JsonRenderer::with_writer(out.clone());
1844        let mut detail = make_resource_detail_no_values();
1845        detail.values = Some(vec![FieldValues {
1846            name: "hasTranscription".into(),
1847            label: None,
1848            values: vec![ValueContent::Text("plain transcription".into()).into()],
1849        }]);
1850        let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1851        renderer.resource_describe(&detail, &meta).unwrap();
1852
1853        let parsed: serde_json::Value = serde_json::from_str(out.string().trim()).unwrap();
1854        let val = &parsed["data"]["values"][0]["values"][0];
1855        assert!(
1856            !val.as_object().unwrap().contains_key("comment"),
1857            "comment key must be absent when comment is None; got: {val}"
1858        );
1859    }
1860}