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