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