Skip to main content

ojak_vocab/
loss.rs

1//! What a typed value did not keep of the document it was read from.
2//!
3//! Reading is tolerant: a value that is not the shape its property allows is
4//! read as absent, and a property the vocabulary does not define is not read
5//! at all, so that one odd property does not cost the whole object. That
6//! tolerance is silent unless something says what it cost. This compares
7//! the normalised document with the value written back, and lists what the
8//! document said that the value does not.
9//!
10//! The comparison is by meaning, not spelling: one value and an array of
11//! one are the same, a JSON-LD value object is the value it wraps, a number
12//! and its decimal string are one number, and a node that is only an `id` is
13//! that IRI. A value the document gave as `null`, or an empty array, is no
14//! value, and nothing is lost by not keeping it.
15
16use alloc::{
17    format,
18    string::{String, ToString},
19    vec::Vec,
20};
21use core::fmt;
22use serde_json::{Map, Value};
23
24/// Something the document said that the value read from it does not.
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct Loss {
27    /// Where, in the normalised document: `object.attachment[1].url`.
28    pub path: String,
29    /// What was there.
30    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/// What `input` says that `output` does not.
45#[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
57/// `value` with each `{key}Map` of language-tagged text folded into `key`, as
58/// language-tagged value objects: the form a normalised document gives text
59/// in, where the written form splits it into `content` and `contentMap`.
60fn 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
103/// The scalar a value stands for: a value object's `@value`, and a node
104/// that is only an `id`, its IRI.
105fn 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
126/// Whether `output` says everything `input` does.
127fn 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        // An IRI given where the value is written as a link or an image.
140        (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        // Two scalars that differ, or a scalar and an object.
211        _ => 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}