tilewright_diff/lib.rs
1// Copyright 2026 Seraphina Studios
2// SPDX-License-Identifier: MIT
3
4//! Compares the IDs of two Tilewright worlds (`docs/spec/DIFF.md`).
5//!
6//! Games keep IDs in saves, server state and scripts: planes, zones,
7//! anchors, stairwells, definitions, render layers and object uids. Removing or renaming
8//! one breaks whatever refers to it. A [`Snapshot`] holds the IDs of one
9//! world, read from a source tree or a content pack, with a fingerprint of
10//! each one's content. [`diff`] compares two snapshots and reports which IDs
11//! were removed, added and renamed, and which removals an [`Allowlist`]
12//! declares intended.
13//!
14//! ```no_run
15//! # use std::path::Path;
16//! let limits = tilewright_diff::Limits::default();
17//! let old = tilewright_diff::load(Path::new("baseline.twpack"), &limits)?;
18//! let new = tilewright_diff::load(Path::new("content"), &limits)?;
19//! let report = tilewright_diff::diff(&old, &new, &tilewright_diff::Allowlist::default());
20//! print!("{}", report.to_text());
21//! if report.breaking() {
22//! eprintln!("IDs were removed or renamed");
23//! }
24//! # Ok::<(), tilewright_diff::Error>(())
25//! ```
26
27mod allow;
28mod compare;
29mod report;
30mod snapshot;
31
32pub use allow::{Allowance, Allowlist, AllowlistError};
33pub use compare::diff;
34pub use report::{Added, Basis, Reason, Removed, Renamed, Report, Unused};
35pub use snapshot::{Input, Side, Snapshot};
36
37use std::{fmt, path::Path};
38use tilewright_source::Diagnostic;
39
40/// The `format` of a JSON report.
41pub const FORMAT: &str = "tilewright.diff/1";
42
43/// The `format` of an allowlist document.
44pub const ALLOWLIST_FORMAT: &str = "tilewright.diff-allow/1";
45
46/// The default [`Limits::maximum_ids`].
47pub const DEFAULT_MAXIMUM_IDS: usize = 1_000_000;
48
49/// Bounds on what a snapshot may hold. A snapshot keeps every ID of its
50/// world with a fingerprint, so its memory grows with its IDs.
51///
52/// These bound the snapshot, not loading: a pack's rows are counted as
53/// they are read, so a pack with too many IDs stops at the first one past
54/// the limit, but a source tree is loaded whole first, within the bounds of
55/// [`tilewright_source::LoadOptions`].
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub struct Limits {
58 /// Most IDs of all kinds one snapshot holds. Building a snapshot of a
59 /// world with more stops at the first ID past the limit, before it is
60 /// kept.
61 pub maximum_ids: usize,
62}
63
64impl Default for Limits {
65 fn default() -> Self {
66 Self {
67 maximum_ids: DEFAULT_MAXIMUM_IDS,
68 }
69 }
70}
71
72/// A kind of ID. Kinds sort by name.
73#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
74pub enum Kind {
75 /// An anchor of a plane, written `<plane>/<id>`.
76 Anchor,
77 /// A definition, written `<type>/<id>`.
78 Definition,
79 /// An object uid, written `<uid>`: uids are unique in the world.
80 Object,
81 /// A plane, written `<id>`.
82 Plane,
83 /// A render layer of the profile, written `<name>`.
84 RenderLayer,
85 /// A stairwell of a plane, written `<plane>/<id>`.
86 Stairwell,
87 /// A zone of a plane, written `<plane>/<id>`.
88 Zone,
89}
90
91impl Kind {
92 /// Every kind, in order.
93 pub const ALL: [Self; 7] = [
94 Self::Anchor,
95 Self::Definition,
96 Self::Object,
97 Self::Plane,
98 Self::RenderLayer,
99 Self::Stairwell,
100 Self::Zone,
101 ];
102
103 #[must_use]
104 pub const fn as_str(self) -> &'static str {
105 match self {
106 Self::Anchor => "anchor",
107 Self::Definition => "definition",
108 Self::Object => "object",
109 Self::Plane => "plane",
110 Self::RenderLayer => "render_layer",
111 Self::Stairwell => "stairwell",
112 Self::Zone => "zone",
113 }
114 }
115
116 /// The kind spelled `text`.
117 #[must_use]
118 pub fn parse(text: &str) -> Option<Self> {
119 Self::ALL.into_iter().find(|kind| kind.as_str() == text)
120 }
121
122 /// Whether a removed and an added ID of this kind may pair as a rename
123 /// by having the same content. Object uids pair only by a declared
124 /// rename: an object's content and place change freely while it keeps
125 /// its uid, so neither shows that two uids name one object.
126 #[must_use]
127 pub const fn pairs_by_content(self) -> bool {
128 !matches!(self, Self::Object)
129 }
130
131 /// Whether IDs of this kind belong to a plane and are written
132 /// `<plane>/<id>`.
133 #[must_use]
134 pub const fn in_plane(self) -> bool {
135 matches!(self, Self::Anchor | Self::Stairwell | Self::Zone)
136 }
137
138 /// Whether `id` is written as IDs of this kind are: two identifiers
139 /// joined by `/` for kinds in a plane and for definitions, else one
140 /// identifier.
141 #[must_use]
142 pub fn is_id(self, id: &str) -> bool {
143 if self.in_plane() || self == Self::Definition {
144 id.split_once('/')
145 .is_some_and(|(scope, local)| is_identifier(scope) && is_identifier(local))
146 } else {
147 is_identifier(id)
148 }
149 }
150
151 /// What an ID of this kind is unique within: its plane or definition
152 /// type, or `""` for planes, render layers and objects.
153 pub(crate) fn scope(self, id: &str) -> &str {
154 if self.in_plane() || self == Self::Definition {
155 id.split_once('/').map_or("", |(scope, _)| scope)
156 } else {
157 ""
158 }
159 }
160}
161
162impl fmt::Display for Kind {
163 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
164 formatter.write_str(self.as_str())
165 }
166}
167
168/// Whether `text` matches `^[a-z0-9][a-z0-9_.-]{0,127}$`, the source
169/// format's identifier pattern.
170pub(crate) fn is_identifier(text: &str) -> bool {
171 let bytes = text.as_bytes();
172 (1..=128).contains(&bytes.len())
173 && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit())
174 && bytes
175 .iter()
176 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"_.-".contains(byte))
177}
178
179/// Why a side of a comparison could not be read.
180#[derive(Debug)]
181pub enum Error {
182 /// The source tree did not load; its diagnostics, which include every
183 /// reference that does not resolve.
184 Source(Vec<Diagnostic>),
185 /// The pack did not open, verify or read.
186 Pack(tilewright_pack::Error),
187 /// The world has more IDs than the limit, which this holds.
188 TooManyIds(usize),
189 /// A document that loaded could not be read again.
190 Invalid(String),
191}
192
193impl fmt::Display for Error {
194 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
195 match self {
196 Self::Source(diagnostics) => {
197 write!(formatter, "{} problem(s) in the source", diagnostics.len())
198 }
199 Self::Pack(error) => error.fmt(formatter),
200 Self::TooManyIds(maximum) => {
201 write!(formatter, "more than {maximum} IDs, the ID limit")
202 }
203 Self::Invalid(message) => formatter.write_str(message),
204 }
205 }
206}
207
208impl std::error::Error for Error {}
209
210/// Reads one side of a comparison: a content pack when `path` is a file,
211/// which is opened and fully verified with the default bounds first, and
212/// otherwise a source tree, which is loaded and validated with the default
213/// bounds. The snapshot holds at most `limits.maximum_ids` IDs; see
214/// [`Limits`] for what that bounds.
215///
216/// # Errors
217/// Returns the source diagnostics, the pack's first problem, or
218/// [`Error::TooManyIds`].
219pub fn load(path: &Path, limits: &Limits) -> Result<Snapshot, Error> {
220 if std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) {
221 // Reading a pack's IDs verifies it first.
222 let pack = tilewright_pack::Pack::open(path).map_err(Error::Pack)?;
223 Snapshot::from_pack(&pack, limits)
224 } else {
225 let world = tilewright_source::load(path).map_err(Error::Source)?;
226 Snapshot::from_world(&world, limits)
227 }
228}
229
230#[cfg(test)]
231mod tests;