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