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] = &[
158 "$schema",
159 "protocol_versions",
160 "capsules",
161 "capsule_signature",
162];
163const CMN_CAPSULE_ENTRY_KEY_ORDER: &[&str] = &["uri", "key", "previous_keys", "endpoints"];
164const CMN_ENDPOINT_KEY_ORDER: &[&str] = &["type", "url", "hashes", "format", "delta_url"];
165const PREVIOUS_KEY_ORDER: &[&str] = &["key", "retired_at_epoch_ms"];
166
167impl PrettyJson for super::Spore {
170 fn to_pretty_json(&self) -> Result<String> {
171 let value = serialize_to_value(self, "Spore")?;
172 format_value(
173 &value,
174 &[
175 ("", SPORE_TOP_KEY_ORDER),
176 ("/capsule", SPORE_CAPSULE_KEY_ORDER),
177 ("/capsule/core", SPORE_CORE_KEY_ORDER),
178 ("/capsule/core/bonds[]", BOND_KEY_ORDER),
179 ("/capsule/core/tree", SPORE_TREE_KEY_ORDER),
180 ],
181 )
182 }
183}
184
185impl PrettyJson for super::SporeCoreDocument {
186 fn to_pretty_json(&self) -> Result<String> {
187 let value = serialize_to_value(self, "SporeCoreDocument")?;
188 format_value(
189 &value,
190 &[
191 ("", SPORE_CORE_KEY_ORDER),
192 ("/bonds[]", BOND_KEY_ORDER),
193 ("/tree", SPORE_TREE_KEY_ORDER),
194 ],
195 )
196 }
197}
198
199pub fn format_spore_core_draft(value: &Value) -> Result<String> {
202 let mut clean = value.clone();
203 if let Some(obj) = clean.as_object_mut() {
204 obj.remove("updated_at_epoch_ms");
205 }
206 format_value(
207 &clean,
208 &[
209 ("", SPORE_CORE_KEY_ORDER),
210 ("/bonds[]", BOND_KEY_ORDER),
211 ("/tree", SPORE_TREE_KEY_ORDER),
212 ],
213 )
214}
215
216impl PrettyJson for super::Mycelium {
217 fn to_pretty_json(&self) -> Result<String> {
218 let value = serialize_to_value(self, "Mycelium")?;
219 format_value(
220 &value,
221 &[
222 ("", MYCELIUM_TOP_KEY_ORDER),
223 ("/capsule", MYCELIUM_CAPSULE_KEY_ORDER),
224 ("/capsule/core", MYCELIUM_CORE_KEY_ORDER),
225 ("/capsule/core/nutrients[]", NUTRIENT_KEY_ORDER),
226 ("/capsule/core/spores[]", MYCELIUM_SPORE_KEY_ORDER),
227 ("/capsule/core/tastes[]", MYCELIUM_TASTE_KEY_ORDER),
228 ],
229 )
230 }
231}
232
233impl PrettyJson for super::Taste {
234 fn to_pretty_json(&self) -> Result<String> {
235 let value = serialize_to_value(self, "Taste")?;
236 format_value(
237 &value,
238 &[
239 ("", TASTE_TOP_KEY_ORDER),
240 ("/capsule", TASTE_CAPSULE_KEY_ORDER),
241 ("/capsule/core", TASTE_CORE_KEY_ORDER),
242 ],
243 )
244 }
245}
246
247impl PrettyJson for super::CmnEntry {
248 fn to_pretty_json(&self) -> Result<String> {
249 let value = serialize_to_value(self, "CmnEntry")?;
250 format_value(
251 &value,
252 &[
253 ("", CMN_TOP_KEY_ORDER),
254 ("/capsules[]", CMN_CAPSULE_ENTRY_KEY_ORDER),
255 ],
256 )
257 }
258}
259
260pub fn format_cmn_entry(value: &Value) -> Result<String> {
263 format_value(
264 value,
265 &[
266 ("", CMN_TOP_KEY_ORDER),
267 ("/capsules[]", CMN_CAPSULE_ENTRY_KEY_ORDER),
268 ],
269 )
270}
271
272impl super::CmnEntry {
273 pub fn to_pretty_json_deep(&self) -> Result<String> {
275 let canonical =
276 serde_jcs::to_string(self).map_err(|e| anyhow!("JCS serialization failed: {}", e))?;
277 let mut sorted: Value =
278 serde_json::from_str(&canonical).map_err(|e| anyhow!("JCS re-parse failed: {}", e))?;
279
280 if let Value::Object(ref map) = sorted.clone() {
282 sorted = Value::Object(order_keys(map, CMN_TOP_KEY_ORDER));
283 }
284
285 if let Some(Value::Array(capsules)) = sorted.pointer_mut("/capsules") {
287 for capsule in capsules.iter_mut() {
288 if let Value::Object(map) = capsule {
289 *map = order_keys(map, CMN_CAPSULE_ENTRY_KEY_ORDER);
290
291 if let Some(Value::Array(endpoints)) = map.get_mut("endpoints") {
293 for ep in endpoints.iter_mut() {
294 if let Value::Object(ep_map) = ep {
295 *ep_map = order_keys(ep_map, CMN_ENDPOINT_KEY_ORDER);
296 }
297 }
298 }
299
300 if let Some(Value::Array(prev_keys)) = map.get_mut("previous_keys") {
302 for pk in prev_keys.iter_mut() {
303 if let Value::Object(pk_map) = pk {
304 *pk_map = order_keys(pk_map, PREVIOUS_KEY_ORDER);
305 }
306 }
307 }
308 }
309 }
310 }
311
312 let pretty = serde_json::to_string_pretty(&sorted)
313 .map_err(|e| anyhow!("Pretty-print failed: {}", e))?;
314 Ok(format!("{}\n", pretty))
315 }
316}
317
318#[cfg(test)]
319#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
320mod tests {
321
322 use super::*;
323 use crate::model::*;
324
325 #[test]
326 fn test_order_keys() {
327 let mut map = Map::new();
328 map.insert("z".to_string(), Value::Null);
329 map.insert("a".to_string(), Value::Null);
330 map.insert("m".to_string(), Value::Null);
331
332 let ordered = order_keys(&map, &["m", "a"]);
333 let keys: Vec<&String> = ordered.keys().collect();
334 assert_eq!(keys, vec!["m", "a", "z"]);
335 }
336
337 #[test]
338 fn test_spore_to_pretty_json_key_order() {
339 let spore = Spore::new(
340 "example.com",
341 "test",
342 "A test",
343 vec!["v1".to_string()],
344 "MIT",
345 );
346 let json = spore.to_pretty_json().unwrap();
347
348 let schema_pos = json.find("\"$schema\"").unwrap();
350 let capsule_pos = json.find("\"capsule\"").unwrap();
351 let capsule_sig_pos = json.find("\"capsule_signature\"").unwrap();
352 assert!(schema_pos < capsule_pos);
353 assert!(capsule_pos < capsule_sig_pos);
354
355 let name_pos = json.find("\"name\"").unwrap();
357 let domain_pos = json.find("\"domain\"").unwrap();
358 let synopsis_pos = json.find("\"synopsis\"").unwrap();
359 assert!(name_pos < domain_pos);
360 assert!(domain_pos < synopsis_pos);
361 }
362
363 #[test]
364 fn test_spore_core_document_key_order() {
365 let doc = SporeCoreDocument {
366 schema: SPORE_CORE_SCHEMA.to_string(),
367 core: SporeCore {
368 id: String::new(),
369 name: "test".to_string(),
370 version: String::new(),
371 domain: "example.com".to_string(),
372 key: String::new(),
373 synopsis: "A test".to_string(),
374 intent: vec![],
375 license: "MIT".to_string(),
376 mutations: vec![],
377 size_bytes: 0,
378 updated_at_epoch_ms: 0,
379 bonds: vec![],
380 tree: SporeTree::default(),
381 },
382 };
383 let json = doc.to_pretty_json().unwrap();
384
385 let schema_pos = json.find("\"$schema\"").unwrap();
386 let name_pos = json.find("\"name\"").unwrap();
387 let domain_pos = json.find("\"domain\"").unwrap();
388 assert!(schema_pos < name_pos);
389 assert!(name_pos < domain_pos);
390 }
391
392 #[test]
393 fn test_format_spore_core_draft_strips_updated_at() {
394 let value = serde_json::json!({
395 "$schema": SPORE_CORE_SCHEMA,
396 "name": "test",
397 "domain": "example.com",
398 "synopsis": "A test",
399 "intent": [],
400 "license": "MIT",
401 "updated_at_epoch_ms": 12345,
402 "tree": {
403 "algorithm": "blob_tree_blake3_nfc",
404 "exclude_names": [],
405 "follow_rules": []
406 }
407 });
408 let json = format_spore_core_draft(&value).unwrap();
409 assert!(!json.contains("updated_at_epoch_ms"));
410 }
411
412 #[test]
413 fn test_mycelium_to_pretty_json_core_key_order() {
414 let mycelium = Mycelium::new("example.com", "Example", "A test", 123);
415 let json = mycelium.to_pretty_json().unwrap();
416
417 let domain_pos = json.find("\"domain\"").unwrap();
419 let name_pos = json.find("\"name\"").unwrap();
420 let synopsis_pos = json.find("\"synopsis\"").unwrap();
421 assert!(domain_pos < name_pos);
422 assert!(name_pos < synopsis_pos);
423 }
424
425 #[test]
426 fn test_cmn_entry_to_pretty_json_deep() {
427 let entry = CmnEntry::new(vec![CmnCapsuleEntry {
428 uri: "cmn://example.com".to_string(),
429 key: "ed25519.abc".to_string(),
430 previous_keys: vec![],
431 endpoints: vec![CmnEndpoint {
432 kind: "mycelium".to_string(),
433 url: "https://example.com/cmn/mycelium/{hash}.json".to_string(),
434 hash: "b3.abc".to_string(),
435 hashes: vec![],
436 format: None,
437 delta_url: None,
438 protocol_version: None,
439 }],
440 }]);
441 let json = entry.to_pretty_json_deep().unwrap();
442
443 let schema_pos = json.find("\"$schema\"").unwrap();
444 let capsules_pos = json.find("\"capsules\"").unwrap();
445 let sig_pos = json.find("\"capsule_signature\"").unwrap();
446 assert!(schema_pos < capsules_pos);
447 assert!(capsules_pos < sig_pos);
448 }
449
450 #[test]
451 fn test_taste_to_pretty_json_key_order() {
452 let taste = Taste {
453 schema: TASTE_SCHEMA.to_string(),
454 capsule: TasteCapsule {
455 uri: "cmn://example.com/taste/b3.abc".to_string(),
456 core: TasteCore {
457 target_uri: "cmn://other.com/b3.xyz".to_string(),
458 domain: "example.com".to_string(),
459 key: "ed25519.abc".to_string(),
460 verdict: TasteVerdict::Safe,
461 notes: vec![],
462 tasted_at_epoch_ms: 123,
463 },
464 core_signature: "ed25519.sig".to_string(),
465 },
466 capsule_signature: "ed25519.capsig".to_string(),
467 };
468 let json = taste.to_pretty_json().unwrap();
469
470 let schema_pos = json.find("\"$schema\"").unwrap();
471 let capsule_pos = json.find("\"capsule\"").unwrap();
472 assert!(schema_pos < capsule_pos);
473 }
474}