1use alloc::{
17 format,
18 string::{String, ToString},
19 vec::Vec,
20};
21use core::fmt;
22use serde_json::{Map, Value};
23
24#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct Loss {
27 pub path: String,
29 pub value: Value,
31}
32
33impl fmt::Display for Loss {
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 let path = if self.path.is_empty() {
36 "(document)"
37 } else {
38 &self.path
39 };
40 write!(f, "{path}: {}", self.value)
41 }
42}
43
44#[must_use]
46pub fn losses(input: &Value, output: &Value) -> Vec<Loss> {
47 let mut found = Vec::new();
48 compare(
49 input,
50 &merge_language_maps(output),
51 String::new(),
52 &mut found,
53 );
54 found
55}
56
57fn merge_language_maps(value: &Value) -> Value {
61 match value {
62 Value::Array(values) => Value::Array(values.iter().map(merge_language_maps).collect()),
63 Value::Object(object) => {
64 let mut merged: Map<String, Value> = object
65 .iter()
66 .map(|(key, value)| (key.clone(), merge_language_maps(value)))
67 .collect();
68 for (key, map) in object {
69 let (Some(base), Value::Object(languages)) = (key.strip_suffix("Map"), map) else {
70 continue;
71 };
72 let mut values = items(merged.get(base).unwrap_or(&Value::Null))
73 .into_iter()
74 .cloned()
75 .collect::<Vec<_>>();
76 for (language, text) in languages {
77 values.push(serde_json::json!({"@language": language, "@value": text}));
78 }
79 merged.insert(base.into(), Value::Array(values));
80 }
81 Value::Object(merged)
82 }
83 other => other.clone(),
84 }
85}
86
87fn nothing(value: &Value) -> bool {
88 match value {
89 Value::Null => true,
90 Value::Array(values) => values.iter().all(nothing),
91 _ => false,
92 }
93}
94
95fn items(value: &Value) -> Vec<&Value> {
96 match value {
97 Value::Array(values) => values.iter().filter(|v| !nothing(v)).collect(),
98 value if nothing(value) => Vec::new(),
99 value => alloc::vec![value],
100 }
101}
102
103fn scalar(value: &Value) -> &Value {
106 match value {
107 Value::Object(object) if object.len() == 1 && object.contains_key("id") => &object["id"],
108 Value::Object(object) if object.contains_key("@value") => &object["@value"],
109 other => other,
110 }
111}
112
113fn same_scalar(input: &Value, output: &Value) -> bool {
114 match (scalar(input), scalar(output)) {
115 (Value::String(a), Value::Number(b)) | (Value::Number(b), Value::String(a)) => {
116 *a == b.to_string() || a.parse::<f64>().ok() == b.as_f64()
117 }
118 (Value::String(a), Value::Bool(b)) | (Value::Bool(b), Value::String(a)) => {
119 *a == if *b { "true" } else { "false" }
120 }
121 (Value::Number(a), Value::Number(b)) => a.as_f64() == b.as_f64(),
122 (a, b) => a == b,
123 }
124}
125
126fn covers(output: &Value, input: &Value) -> bool {
128 if nothing(input) || same_scalar(input, output) {
129 return true;
130 }
131 match (input, output) {
132 (Value::Array(_), _) | (_, Value::Array(_)) => {
133 let outputs = items(output);
134 items(input)
135 .into_iter()
136 .all(|i| outputs.iter().any(|o| covers(o, i)))
137 }
138 (Value::Object(i), Value::Object(o)) => covers_object(o, i),
139 (Value::String(text), Value::Object(o)) => {
141 ["id", "href", "url"]
142 .iter()
143 .any(|key| o.get(*key).is_some_and(|v| covers(v, input)))
144 || o.get("@value").and_then(Value::as_str) == Some(text)
145 }
146 _ => false,
147 }
148}
149
150fn covers_object(output: &Map<String, Value>, input: &Map<String, Value>) -> bool {
151 input.iter().all(|(key, value)| {
152 key == "@context" || nothing(value) || output.get(key).is_some_and(|out| covers(out, value))
153 })
154}
155
156fn join(path: &str, key: &str) -> String {
157 if path.is_empty() {
158 key.into()
159 } else {
160 format!("{path}.{key}")
161 }
162}
163
164fn compare(input: &Value, output: &Value, path: String, found: &mut Vec<Loss>) {
165 if covers(output, input) {
166 return;
167 }
168 match (input, output) {
169 (Value::Object(i), Value::Object(o)) => {
170 for (key, value) in i {
171 if key == "@context" || nothing(value) {
172 continue;
173 }
174 let at = join(&path, key);
175 match o.get(key) {
176 Some(out) => compare(value, out, at, found),
177 None => found.push(Loss {
178 path: at,
179 value: value.clone(),
180 }),
181 }
182 }
183 }
184 (Value::Array(_), _) | (_, Value::Array(_)) => {
185 let inputs = items(input);
186 let outputs = items(output);
187 let listed = matches!(input, Value::Array(values) if values.len() > 1);
188 let at = |n: usize| {
189 if listed {
190 format!("{path}[{n}]")
191 } else {
192 path.clone()
193 }
194 };
195 if inputs.len() == outputs.len() {
196 for (n, (i, o)) in inputs.into_iter().zip(outputs).enumerate() {
197 compare(i, o, at(n), found);
198 }
199 } else {
200 for (n, i) in inputs.into_iter().enumerate() {
201 if !outputs.iter().any(|o| covers(o, i)) {
202 found.push(Loss {
203 path: at(n),
204 value: i.clone(),
205 });
206 }
207 }
208 }
209 }
210 _ => found.push(Loss {
212 path,
213 value: input.clone(),
214 }),
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use serde_json::json;
222
223 #[test]
224 fn the_same_meaning_in_another_spelling_is_nothing_lost() {
225 let input = json!({
226 "id": "https://a.example/1",
227 "to": "https://a.example/followers",
228 "sensitive": {"@value": true},
229 "width": "300",
230 "inReplyTo": null,
231 "tag": [],
232 "attributedTo": {"id": "https://a.example/users/1"},
233 });
234 let output = json!({
235 "id": "https://a.example/1",
236 "to": ["https://a.example/followers"],
237 "sensitive": true,
238 "width": 300,
239 "attributedTo": "https://a.example/users/1",
240 });
241 assert_eq!(losses(&input, &output), []);
242 }
243
244 #[test]
245 fn text_by_language_is_kept_in_its_map() {
246 let input = json!({"content": ["hello", {"@language": "ko", "@value": "안녕"}]});
247 let output = json!({"content": "hello", "contentMap": {"ko": "안녕"}});
248 assert_eq!(losses(&input, &output), []);
249 let output = json!({"content": "hello"});
250 assert_eq!(losses(&input, &output).len(), 1);
251 }
252
253 #[test]
254 fn what_was_not_kept_is_listed_where_it_was() {
255 let input = json!({
256 "name": "a",
257 "unknownTerm": 1,
258 "attachment": [
259 {"type": "Image", "url": "https://a.example/1.png", "blurhash": "xyz"},
260 {"type": "Image", "url": "https://a.example/2.png"},
261 ],
262 "published": "yesterday",
263 });
264 let output = json!({
265 "name": "a",
266 "attachment": [
267 {"type": "Image", "url": "https://a.example/1.png"},
268 {"type": "Image", "url": "https://a.example/2.png"},
269 ],
270 "published": "2026-01-01T00:00:00Z",
271 });
272 let paths: Vec<String> = losses(&input, &output)
273 .into_iter()
274 .map(|l| l.path)
275 .collect();
276 assert_eq!(
277 paths,
278 ["attachment[0].blurhash", "published", "unknownTerm"]
279 );
280 }
281}