Skip to main content

tilewright_diff/
report.rs

1// Copyright 2026 Seraphina Studios
2// SPDX-License-Identifier: MIT
3
4//! Reports: what changed between two snapshots, as text or as JSON in the
5//! `tilewright.diff/1` format.
6
7use crate::{Allowance, FORMAT, Kind, Side};
8use std::fmt::Write as _;
9
10/// The result of [`crate::diff`]. Every list is sorted.
11#[derive(Clone, Debug, Eq, PartialEq)]
12pub struct Report {
13    pub old: Side,
14    pub new: Side,
15    /// IDs of the old world that the new one lacks and that no rename
16    /// pairs, by kind and ID.
17    pub removed: Vec<Removed>,
18    /// Removed IDs paired with an ID of the new world, by kind, old ID and
19    /// new ID.
20    pub renamed: Vec<Renamed>,
21    /// IDs of the new world that the old one lacks and that no rename
22    /// pairs, by kind and ID.
23    pub added: Vec<Added>,
24    /// Allowlist entries that did not apply, by kind and old ID.
25    pub unused: Vec<Unused>,
26}
27
28#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
29pub struct Removed {
30    pub kind: Kind,
31    pub id: String,
32    /// Whether the allowlist accepts the removal.
33    pub allowed: bool,
34}
35
36#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
37pub struct Renamed {
38    pub kind: Kind,
39    pub from: String,
40    pub to: String,
41    pub basis: Basis,
42    /// Whether the allowlist accepts the removal of `from`.
43    pub allowed: bool,
44}
45
46#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
47pub struct Added {
48    pub kind: Kind,
49    pub id: String,
50}
51
52/// Why a removed ID and a new one are reported as a rename.
53#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
54pub enum Basis {
55    /// The allowlist declares it.
56    Declared,
57    /// They are the only removed and added IDs of their kind and scope with
58    /// the same content.
59    Content,
60}
61
62impl Basis {
63    #[must_use]
64    pub const fn as_str(self) -> &'static str {
65        match self {
66            Self::Declared => "declared",
67            Self::Content => "content",
68        }
69    }
70}
71
72/// An allowlist entry that did not apply.
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct Unused {
75    pub allowance: Allowance,
76    pub reason: Reason,
77}
78
79#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80pub enum Reason {
81    /// The ID is not in the old world, or is still in the new one.
82    NotRemoved,
83    /// The rename's new ID is not in the new world, so the removal is not
84    /// accepted.
85    TargetMissing,
86}
87
88impl Reason {
89    #[must_use]
90    pub const fn as_str(self) -> &'static str {
91        match self {
92            Self::NotRemoved => "not_removed",
93            Self::TargetMissing => "target_missing",
94        }
95    }
96}
97
98impl Report {
99    /// Whether an ID was removed or renamed without the allowlist accepting
100    /// it.
101    #[must_use]
102    pub fn breaking(&self) -> bool {
103        self.removed.iter().any(|removed| !removed.allowed)
104            || self.renamed.iter().any(|renamed| !renamed.allowed)
105    }
106
107    /// The removed and renamed IDs the allowlist does not accept.
108    #[must_use]
109    pub fn not_allowed(&self) -> usize {
110        self.removed
111            .iter()
112            .filter(|removed| !removed.allowed)
113            .count()
114            + self
115                .renamed
116                .iter()
117                .filter(|renamed| !renamed.allowed)
118                .count()
119    }
120
121    /// The report as one line of JSON in the `tilewright.diff/1` format,
122    /// with a final newline. Every control character in a string is
123    /// escaped, so the output is safe to print to a terminal.
124    #[must_use]
125    pub fn to_json(&self) -> String {
126        let mut output = String::from("{\"format\":");
127        json_string(FORMAT, &mut output);
128        output.push_str(",\"old\":");
129        side_json(&self.old, &mut output);
130        output.push_str(",\"new\":");
131        side_json(&self.new, &mut output);
132        let _ = write!(output, ",\"breaking\":{}", self.breaking());
133        output.push_str(",\"removed\":");
134        list(&mut output, &self.removed, |removed, output| {
135            entry(output, removed.kind, &[("id", &removed.id)]);
136            let _ = write!(output, ",\"allowed\":{}}}", removed.allowed);
137        });
138        output.push_str(",\"renamed\":");
139        list(&mut output, &self.renamed, |renamed, output| {
140            entry(
141                output,
142                renamed.kind,
143                &[
144                    ("from", &renamed.from),
145                    ("to", &renamed.to),
146                    ("basis", renamed.basis.as_str()),
147                ],
148            );
149            let _ = write!(output, ",\"allowed\":{}}}", renamed.allowed);
150        });
151        output.push_str(",\"added\":");
152        list(&mut output, &self.added, |added, output| {
153            entry(output, added.kind, &[("id", &added.id)]);
154            output.push('}');
155        });
156        output.push_str(",\"unused\":");
157        list(&mut output, &self.unused, |unused, output| {
158            output.push_str("{\"allowance\":");
159            match &unused.allowance {
160                Allowance::Remove { kind, id } => {
161                    json_string("remove", output);
162                    output.push(',');
163                    entry_members(output, *kind, &[("id", id)]);
164                }
165                Allowance::Rename { kind, from, to } => {
166                    json_string("rename", output);
167                    output.push(',');
168                    entry_members(output, *kind, &[("from", from), ("to", to)]);
169                }
170            }
171            output.push_str(",\"reason\":");
172            json_string(unused.reason.as_str(), output);
173            output.push('}');
174        });
175        output.push_str("}\n");
176        output
177    }
178
179    /// The report as text: one line per removed, renamed and added ID, one
180    /// per unused allowlist entry, and a summary line. IDs are identifiers,
181    /// so no line carries control characters.
182    #[must_use]
183    pub fn to_text(&self) -> String {
184        let allowed = |allowed: bool| if allowed { " (allowed)" } else { "" };
185        let mut output = String::new();
186        for removed in &self.removed {
187            let _ = writeln!(
188                output,
189                "removed {} {}{}",
190                removed.kind,
191                removed.id,
192                allowed(removed.allowed)
193            );
194        }
195        for renamed in &self.renamed {
196            let basis = match renamed.basis {
197                crate::Basis::Declared => "declared",
198                crate::Basis::Content => "same content",
199            };
200            let _ = writeln!(
201                output,
202                "renamed {} {} -> {} ({basis}{})",
203                renamed.kind,
204                renamed.from,
205                renamed.to,
206                if renamed.allowed { ", allowed" } else { "" }
207            );
208        }
209        for added in &self.added {
210            let _ = writeln!(output, "added {} {}", added.kind, added.id);
211        }
212        for unused in &self.unused {
213            let reason = match unused.reason {
214                Reason::NotRemoved => "not removed",
215                Reason::TargetMissing => "target missing",
216            };
217            let _ = match &unused.allowance {
218                Allowance::Remove { kind, id } => {
219                    writeln!(output, "unused allowance: remove {kind} {id} ({reason})")
220                }
221                Allowance::Rename { kind, from, to } => writeln!(
222                    output,
223                    "unused allowance: rename {kind} {from} -> {to} ({reason})"
224                ),
225            };
226        }
227        let _ = writeln!(
228            output,
229            "summary: {} removed, {} renamed, {} added, {} not allowed",
230            self.removed.len(),
231            self.renamed.len(),
232            self.added.len(),
233            self.not_allowed()
234        );
235        output
236    }
237}
238
239/// Writes `items` as a JSON array, each with `write`.
240fn list<T>(output: &mut String, items: &[T], mut write: impl FnMut(&T, &mut String)) {
241    output.push('[');
242    for (index, item) in items.iter().enumerate() {
243        if index > 0 {
244            output.push(',');
245        }
246        write(item, output);
247    }
248    output.push(']');
249}
250
251/// Opens an entry object with its kind and string members, leaving it open.
252fn entry(output: &mut String, kind: Kind, members: &[(&str, &str)]) {
253    output.push('{');
254    entry_members(output, kind, members);
255}
256
257fn entry_members(output: &mut String, kind: Kind, members: &[(&str, &str)]) {
258    output.push_str("\"kind\":");
259    json_string(kind.as_str(), output);
260    for (key, value) in members {
261        output.push(',');
262        json_string(key, output);
263        output.push(':');
264        json_string(value, output);
265    }
266}
267
268fn side_json(side: &Side, output: &mut String) {
269    output.push_str("{\"input\":");
270    json_string(side.input.as_str(), output);
271    output.push_str(",\"profile\":");
272    json_string(&side.profile, output);
273    output.push_str(",\"profile_version\":");
274    json_string(&side.profile_version, output);
275    output.push_str(",\"source_digest\":");
276    json_string(&side.source_digest, output);
277    output.push('}');
278}
279
280/// Writes `text` as a JSON string with every control character escaped in
281/// JSON's `\u00XX` form, DEL and C1 controls included.
282fn json_string(text: &str, output: &mut String) {
283    output.push('"');
284    for character in text.chars() {
285        match character {
286            '"' => output.push_str("\\\""),
287            '\\' => output.push_str("\\\\"),
288            character if character.is_control() => {
289                let _ = write!(output, "\\u{:04x}", u32::from(character));
290            }
291            character => output.push(character),
292        }
293    }
294    output.push('"');
295}