Skip to main content

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;