1use anyhow::{anyhow, Result};
2use serde::Serialize;
3use serde_json::{Map, Value};
4
5pub trait PrettyJson {
7 fn to_pretty_json(&self) -> Result<String>;
8}
9
10fn order_keys(map: &Map<String, Value>, key_order: &[&str]) -> Map<String, Value> {
14 let mut ordered = Map::with_capacity(map.len());
15 for &key in key_order {
16 if let Some(v) = map.get(key) {
17 ordered.insert(key.to_string(), v.clone());
18 }
19 }
20 for (k, v) in map {
21 if !ordered.contains_key(k) {
22 ordered.insert(k.clone(), v.clone());
23 }
24 }
25 ordered
26}
27
28fn order_keys_at(value: &mut Value, pointer: &str, key_order: &[&str]) {
30 if let Some(obj) = value.pointer(pointer).and_then(|v| v.as_object().cloned()) {
31 let ordered = order_keys(&obj, key_order);
32 if let Some(last_slash) = pointer.rfind('/') {
34 let parent_path = &pointer[..last_slash];
35 let child_key = &pointer[last_slash + 1..];
36 let parent = if parent_path.is_empty() {
37 Some(value as &mut Value)
38 } else {
39 value.pointer_mut(parent_path)
40 };
41 if let Some(Value::Object(parent_map)) = parent {
42 parent_map.insert(child_key.to_string(), Value::Object(ordered));
43 }
44 } else if pointer.is_empty() {
45 *value = Value::Object(ordered);
46 }
47 }
48}
49
50fn order_array_elements_at(value: &mut Value, pointer: &str, key_order: &[&str]) {
52 if let Some(Value::Array(arr)) = value.pointer_mut(pointer) {
53 for item in arr.iter_mut() {
54 if let Value::Object(map) = item {
55 *map = order_keys(map, key_order);
56 }
57 }
58 }
59}
60
61fn format_value(value: &Value, orderings: &[(&str, &[&str])]) -> Result<String> {
66 let canonical =
67 serde_jcs::to_string(value).map_err(|e| anyhow!("JCS serialization failed: {}", e))?;
68 let mut sorted: Value =
69 serde_json::from_str(&canonical).map_err(|e| anyhow!("JCS re-parse failed: {}", e))?;
70
71 for &(pointer, key_order) in orderings {
72 if let Some(array_pointer) = pointer.strip_suffix("[]") {
73 order_array_elements_at(&mut sorted, array_pointer, key_order);
74 } else {
75 order_keys_at(&mut sorted, pointer, key_order);
76 }
77 }
78
79 let pretty =
80 serde_json::to_string_pretty(&sorted).map_err(|e| anyhow!("Pretty-print failed: {}", e))?;
81 Ok(format!("{}\n", pretty))
82}
83
84fn serialize_to_value<T: Serialize>(value: &T, label: &str) -> Result<Value> {
85 serde_json::to_value(value).map_err(|e| anyhow!("{} serialization failed: {}", label, e))
86}
87
88const SPORE_TOP_KEY_ORDER: &[&str] = &["$schema", "capsule", "capsule_signature"];
91const SPORE_CAPSULE_KEY_ORDER: &[&str] = &["uri", "core", "core_signature", "dist"];
92const SPORE_CORE_KEY_ORDER: &[&str] = &[
97 "$schema",
98 "id",
99 "name",
100 "version",
101 "domain",
102 "key",
103 "synopsis",
104 "intent",
105 "license",
106 "mutations",
107 "updated_at_epoch_ms",
108 "bonds",
109 "tree",
110];
111const BOND_KEY_ORDER: &[&str] = &["relation", "uri", "id", "reason", "with"];
112const SPORE_TREE_KEY_ORDER: &[&str] = &["algorithm", "exclude_names", "follow_rules"];
113
114const MYCELIUM_TOP_KEY_ORDER: &[&str] = &["$schema", "capsule", "capsule_signature"];
117const MYCELIUM_CAPSULE_KEY_ORDER: &[&str] = &["uri", "core", "core_signature"];
118const MYCELIUM_CORE_KEY_ORDER: &[&str] = &[
119 "domain",
120 "key",
121 "name",
122 "synopsis",
123 "bio",
124 "nutrients",
125 "updated_at_epoch_ms",
126 "spores",
127 "tastes",
128];
129const NUTRIENT_KEY_ORDER: &[&str] = &[
130 "type",
131 "address",
132 "recipient",
133 "url",
134 "label",
135 "chain_id",
136 "token",
137 "asset_id",
138];
139const MYCELIUM_SPORE_KEY_ORDER: &[&str] = &["id", "hash", "name", "synopsis"];
140const MYCELIUM_TASTE_KEY_ORDER: &[&str] = &["hash", "target_uri"];
141
142const TASTE_TOP_KEY_ORDER: &[&str] = &["$schema", "capsule", "capsule_signature"];
145const TASTE_CAPSULE_KEY_ORDER: &[&str] = &["uri", "core", "core_signature"];
146const TASTE_CORE_KEY_ORDER: &[&str] = &[
147 "domain",
148 "key",
149 "target_uri",
150 "verdict",
151 "notes",
152 "tasted_at_epoch_ms",
153];
154
155const CMN_TOP_KEY_ORDER: &[&str] = &["$schema", "capsules", "capsule_signature"];
158const CMN_CAPSULE_ENTRY_KEY_ORDER: &[&str] = &["uri", "serial", "key", "history", "endpoints"];
159const CMN_ENDPOINT_KEY_ORDER: &[&str] = &["type", "url", "hashes", "format", "delta_url"];
160const KEY_HISTORY_ORDER: &[&str] = &[
161 "key",
162 "status",
163 "retired_at_epoch_ms",
164 "replaced_by",
165 "effective_serial",
166 "rotation_signature",
167 "revoked_at_epoch_ms",
168];
169
170impl PrettyJson for super::Spore {
173 fn to_pretty_json(&self) -> Result<String> {
174 let value = serialize_to_value(self, "Spore")?;
175 format_value(
176 &value,
177 &[
178 ("", SPORE_TOP_KEY_ORDER),
179 ("/capsule", SPORE_CAPSULE_KEY_ORDER),
180 ("/capsule/core", SPORE_CORE_KEY_ORDER),
181 ("/capsule/core/bonds[]", BOND_KEY_ORDER),
182 ("/capsule/core/tree", SPORE_TREE_KEY_ORDER),
183 ],
184 )
185 }
186}
187
188impl PrettyJson for super::SporeCoreDocument {
189 fn to_pretty_json(&self) -> Result<String> {
190 let value = serialize_to_value(self, "SporeCoreDocument")?;
191 format_value(
192 &value,
193 &[
194 ("", SPORE_CORE_KEY_ORDER),
195 ("/bonds[]", BOND_KEY_ORDER),
196 ("/tree", SPORE_TREE_KEY_ORDER),
197 ],
198 )
199 }
200}
201
202pub fn format_spore_core_draft(value: &Value) -> Result<String> {
205 let mut clean = value.clone();
206 if let Some(obj) = clean.as_object_mut() {
207 obj.remove("updated_at_epoch_ms");
208 }
209 format_value(
210 &clean,
211 &[
212 ("", SPORE_CORE_KEY_ORDER),
213 ("/bonds[]", BOND_KEY_ORDER),
214 ("/tree", SPORE_TREE_KEY_ORDER),
215 ],
216 )
217}
218
219impl PrettyJson for super::Mycelium {
220 fn to_pretty_json(&self) -> Result<String> {
221 let value = serialize_to_value(self, "Mycelium")?;
222 format_value(
223 &value,
224 &[
225 ("", MYCELIUM_TOP_KEY_ORDER),
226 ("/capsule", MYCELIUM_CAPSULE_KEY_ORDER),
227 ("/capsule/core", MYCELIUM_CORE_KEY_ORDER),
228 ("/capsule/core/nutrients[]", NUTRIENT_KEY_ORDER),
229 ("/capsule/core/spores[]", MYCELIUM_SPORE_KEY_ORDER),
230 ("/capsule/core/tastes[]", MYCELIUM_TASTE_KEY_ORDER),
231 ],
232 )
233 }
234}
235
236impl PrettyJson for super::Taste {
237 fn to_pretty_json(&self) -> Result<String> {
238 let value = serialize_to_value(self, "Taste")?;
239 format_value(
240 &value,
241 &[
242 ("", TASTE_TOP_KEY_ORDER),
243 ("/capsule", TASTE_CAPSULE_KEY_ORDER),
244 ("/capsule/core", TASTE_CORE_KEY_ORDER),
245 ],
246 )
247 }
248}
249
250impl PrettyJson for super::CmnEntry {
251 fn to_pretty_json(&self) -> Result<String> {
252 let value = serialize_to_value(self, "CmnEntry")?;
253 format_value(
254 &value,
255 &[
256 ("", CMN_TOP_KEY_ORDER),
257 ("/capsules[]", CMN_CAPSULE_ENTRY_KEY_ORDER),
258 ],
259 )
260 }
261}
262
263pub fn format_cmn_entry(value: &Value) -> Result<String> {
266 format_value(
267 value,
268 &[
269 ("", CMN_TOP_KEY_ORDER),
270 ("/capsules[]", CMN_CAPSULE_ENTRY_KEY_ORDER),
271 ],
272 )
273}
274
275impl super::CmnEntry {
276 pub fn to_pretty_json_deep(&self) -> Result<String> {
278 let canonical =
279 serde_jcs::to_string(self).map_err(|e| anyhow!("JCS serialization failed: {}", e))?;
280 let mut sorted: Value =
281 serde_json::from_str(&canonical).map_err(|e| anyhow!("JCS re-parse failed: {}", e))?;
282
283 if let Value::Object(ref map) = sorted.clone() {
285 sorted = Value::Object(order_keys(map, CMN_TOP_KEY_ORDER));
286 }
287
288 if let Some(Value::Array(capsules)) = sorted.pointer_mut("/capsules") {
290 for capsule in capsules.iter_mut() {
291 if let Value::Object(map) = capsule {
292 *map = order_keys(map, CMN_CAPSULE_ENTRY_KEY_ORDER);
293
294 if let Some(Value::Array(endpoints)) = map.get_mut("endpoints") {
296 for ep in endpoints.iter_mut() {
297 if let Value::Object(ep_map) = ep {
298 *ep_map = order_keys(ep_map, CMN_ENDPOINT_KEY_ORDER);
299 }
300 }
301 }
302
303 if let Some(Value::Array(history)) = map.get_mut("history") {
305 for item in history.iter_mut() {
306 if let Value::Object(history_map) = item {
307 *history_map = order_keys(history_map, KEY_HISTORY_ORDER);
308 }
309 }
310 }
311 }
312 }
313 }
314
315 let pretty = serde_json::to_string_pretty(&sorted)
316 .map_err(|e| anyhow!("Pretty-print failed: {}", e))?;
317 Ok(format!("{}\n", pretty))
318 }
319}
320
321#[cfg(test)]
322#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
323mod tests {
324
325 use super::*;
326 use crate::model::*;
327
328 #[test]
329 fn test_order_keys() {
330 let mut map = Map::new();
331 map.insert("z".to_string(), Value::Null);
332 map.insert("a".to_string(), Value::Null);
333 map.insert("m".to_string(), Value::Null);
334
335 let ordered = order_keys(&map, &["m", "a"]);
336 let keys: Vec<&String> = ordered.keys().collect();
337 assert_eq!(keys, vec!["m", "a", "z"]);
338 }
339
340 #[test]
341 fn test_spore_to_pretty_json_key_order() {
342 let spore = Spore::new(
343 "example.com",
344 "test",
345 "A test",
346 vec!["v1".to_string()],
347 "MIT",
348 );
349 let json = spore.to_pretty_json().unwrap();
350
351 let schema_pos = json.find("\"$schema\"").unwrap();
353 let capsule_pos = json.find("\"capsule\"").unwrap();
354 let capsule_sig_pos = json.find("\"capsule_signature\"").unwrap();
355 assert!(schema_pos < capsule_pos);
356 assert!(capsule_pos < capsule_sig_pos);
357
358 let name_pos = json.find("\"name\"").unwrap();
360 let domain_pos = json.find("\"domain\"").unwrap();
361 let synopsis_pos = json.find("\"synopsis\"").unwrap();
362 assert!(name_pos < domain_pos);
363 assert!(domain_pos < synopsis_pos);
364 }
365
366 #[test]
367 fn test_spore_core_document_key_order() {
368 let doc = SporeCoreDocument {
369 schema: SPORE_CORE_SCHEMA.to_string(),
370 core: SporeCore {
371 id: String::new(),
372 name: "test".to_string(),
373 version: String::new(),
374 domain: "example.com".to_string(),
375 key: String::new(),
376 synopsis: "A test".to_string(),
377 intent: vec![],
378 license: "MIT".to_string(),
379 mutations: vec![],
380 size_bytes: 0,
381 updated_at_epoch_ms: 0,
382 bonds: vec![],
383 tree: SporeTree::default(),
384 },
385 };
386 let json = doc.to_pretty_json().unwrap();
387
388 let schema_pos = json.find("\"$schema\"").unwrap();
389 let name_pos = json.find("\"name\"").unwrap();
390 let domain_pos = json.find("\"domain\"").unwrap();
391 assert!(schema_pos < name_pos);
392 assert!(name_pos < domain_pos);
393 }
394
395 #[test]
396 fn test_format_spore_core_draft_strips_updated_at() {
397 let value = serde_json::json!({
398 "$schema": SPORE_CORE_SCHEMA,
399 "name": "test",
400 "domain": "example.com",
401 "synopsis": "A test",
402 "intent": [],
403 "license": "MIT",
404 "updated_at_epoch_ms": 12345,
405 "tree": {
406 "algorithm": "blob_tree_blake3_nfc",
407 "exclude_names": [],
408 "follow_rules": []
409 }
410 });
411 let json = format_spore_core_draft(&value).unwrap();
412 assert!(!json.contains("updated_at_epoch_ms"));
413 }
414
415 #[test]
416 fn test_mycelium_to_pretty_json_core_key_order() {
417 let mycelium = Mycelium::new("example.com", "Example", "A test", 123);
418 let json = mycelium.to_pretty_json().unwrap();
419
420 let domain_pos = json.find("\"domain\"").unwrap();
422 let name_pos = json.find("\"name\"").unwrap();
423 let synopsis_pos = json.find("\"synopsis\"").unwrap();
424 assert!(domain_pos < name_pos);
425 assert!(name_pos < synopsis_pos);
426 }
427
428 #[test]
429 fn test_cmn_entry_to_pretty_json_deep() {
430 let entry = CmnEntry::new(vec![CmnCapsuleEntry {
431 uri: "cmn://example.com".to_string(),
432 serial: 1,
433 key: "ed25519.abc".to_string(),
434 history: vec![],
435 endpoints: vec![CmnEndpoint {
436 kind: "mycelium".to_string(),
437 url: "https://example.com/cmn/mycelium/{hash}.json".to_string(),
438 hash: "b3.abc".to_string(),
439 hashes: vec![],
440 format: None,
441 delta_url: None,
442 }],
443 }]);
444 let json = entry.to_pretty_json_deep().unwrap();
445
446 let schema_pos = json.find("\"$schema\"").unwrap();
447 let capsules_pos = json.find("\"capsules\"").unwrap();
448 let sig_pos = json.find("\"capsule_signature\"").unwrap();
449 assert!(schema_pos < capsules_pos);
450 assert!(capsules_pos < sig_pos);
451 }
452
453 #[test]
454 fn test_taste_to_pretty_json_key_order() {
455 let taste = Taste {
456 schema: TASTE_SCHEMA.to_string(),
457 capsule: TasteCapsule {
458 uri: "cmn://example.com/taste/b3.abc".to_string(),
459 core: TasteCore {
460 target_uri: "cmn://other.com/b3.xyz".to_string(),
461 domain: "example.com".to_string(),
462 key: "ed25519.abc".to_string(),
463 verdict: TasteVerdict::Safe,
464 notes: vec![],
465 tasted_at_epoch_ms: 123,
466 },
467 core_signature: "ed25519.sig".to_string(),
468 },
469 capsule_signature: "ed25519.capsig".to_string(),
470 };
471 let json = taste.to_pretty_json().unwrap();
472
473 let schema_pos = json.find("\"$schema\"").unwrap();
474 let capsule_pos = json.find("\"capsule\"").unwrap();
475 assert!(schema_pos < capsule_pos);
476 }
477}