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