Skip to main content

tilewright_diff/
allow.rs

1// Copyright 2026 Seraphina Studios
2// SPDX-License-Identifier: MIT
3
4//! Allowlists (`tilewright.diff-allow/1`): the removals and renames a change
5//! intends, so that a comparison accepts them.
6
7use crate::{ALLOWLIST_FORMAT, Kind};
8use std::{collections::BTreeMap, fmt, io::Read as _, path::Path};
9use tilewright_jsonc::{Document, ErrorCode, Limits, Span, Value};
10
11/// Intended removals and renames, at most one for each old ID.
12#[derive(Clone, Debug, Default, Eq, PartialEq)]
13pub struct Allowlist {
14    /// By kind and old ID: `None` for a removal, the new ID for a rename.
15    entries: BTreeMap<Kind, BTreeMap<String, Option<String>>>,
16}
17
18/// One allowlist entry.
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub enum Allowance {
21    /// `id` may be removed.
22    Remove { kind: Kind, id: String },
23    /// `from` may be removed when `to` exists in the new world.
24    Rename {
25        kind: Kind,
26        from: String,
27        to: String,
28    },
29}
30
31impl Allowance {
32    #[must_use]
33    pub const fn kind(&self) -> Kind {
34        match self {
35            Self::Remove { kind, .. } | Self::Rename { kind, .. } => *kind,
36        }
37    }
38
39    /// The ID the entry lets go: the removed ID, or the rename's old ID.
40    #[must_use]
41    pub fn id(&self) -> &str {
42        match self {
43            Self::Remove { id, .. } => id,
44            Self::Rename { from, .. } => from,
45        }
46    }
47}
48
49/// What an entry intends for its old ID.
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub(crate) enum Intent<'a> {
52    Remove,
53    /// A rename to this new ID.
54    Rename(&'a str),
55}
56
57/// Why an allowlist could not be read: the first problem found.
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct AllowlistError {
60    /// The 1-based line and column, in characters; both 0 when the problem
61    /// has no position, such as an unreadable file.
62    pub line: usize,
63    pub column: usize,
64    /// A stable code, as source diagnostics use: `source.io`,
65    /// `source.encoding`, `jsonc.*`, `shape.*` or `ref.duplicate`.
66    pub code: &'static str,
67    pub message: String,
68}
69
70impl AllowlistError {
71    fn file(code: &'static str, message: impl Into<String>) -> Self {
72        Self {
73            line: 0,
74            column: 0,
75            code,
76            message: message.into(),
77        }
78    }
79
80    /// An error at byte `offset` of `text`.
81    fn at(text: &str, offset: usize, code: &'static str, message: impl Into<String>) -> Self {
82        let mut boundary = offset.min(text.len());
83        while !text.is_char_boundary(boundary) {
84            boundary -= 1;
85        }
86        let before = &text[..boundary];
87        let line_start = before.rfind('\n').map_or(0, |index| index + 1);
88        Self {
89            line: before.bytes().filter(|byte| *byte == b'\n').count() + 1,
90            column: before[line_start..].chars().count() + 1,
91            code,
92            message: message.into(),
93        }
94    }
95}
96
97impl fmt::Display for AllowlistError {
98    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99        if self.line == 0 {
100            write!(formatter, "{}: {}", self.code, self.message)
101        } else {
102            write!(
103                formatter,
104                "{}:{}: {}: {}",
105                self.line, self.column, self.code, self.message
106            )
107        }
108    }
109}
110
111impl std::error::Error for AllowlistError {}
112
113impl Allowlist {
114    /// Reads an allowlist file of at most the format's 64 MiB input limit.
115    ///
116    /// # Errors
117    /// Returns the first problem: an unreadable or oversized file, text
118    /// that is not UTF-8, or any problem [`Allowlist::parse`] reports.
119    pub fn read(path: &Path) -> Result<Self, AllowlistError> {
120        let limit = Limits::default().max_input_bytes;
121        let io = |error: std::io::Error| AllowlistError::file("source.io", error.to_string());
122        let mut bytes = Vec::new();
123        std::fs::File::open(path)
124            .map_err(io)?
125            .take(u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1))
126            .read_to_end(&mut bytes)
127            .map_err(io)?;
128        if bytes.len() > limit {
129            return Err(AllowlistError::file(
130                ErrorCode::InputTooLarge.as_str(),
131                format!("an allowlist is at most {limit} bytes"),
132            ));
133        }
134        let text = String::from_utf8(bytes)
135            .map_err(|_| AllowlistError::file("source.encoding", "an allowlist is UTF-8"))?;
136        Self::parse(&text)
137    }
138
139    /// Parses an allowlist document: strict JSONC with exactly the keys of
140    /// `docs/spec/DIFF.md`, kinds and IDs written as the kinds require, and
141    /// at most one entry for each old ID.
142    ///
143    /// # Errors
144    /// Returns the first problem found.
145    pub fn parse(text: &str) -> Result<Self, AllowlistError> {
146        let document =
147            Document::parse(text, &Limits::default()).map_err(|error| AllowlistError {
148                line: error.line,
149                column: error.column,
150                code: error.code.as_str(),
151                message: "invalid JSONC".into(),
152            })?;
153        let fail = |span: Span, code: &'static str, message: String| {
154            AllowlistError::at(text, span.start, code, message)
155        };
156        let root = document.root();
157        closed(root, &["format", "remove", "rename"], &fail)?;
158        match root.get("format") {
159            Some(value) if value.as_str() == Some(ALLOWLIST_FORMAT) => {}
160            Some(value) => {
161                return Err(fail(
162                    value.span(),
163                    "shape.format",
164                    format!("format must be \"{ALLOWLIST_FORMAT}\""),
165                ));
166            }
167            None => {
168                return Err(fail(
169                    root.span(),
170                    "shape.missing_key",
171                    "missing \"format\"".into(),
172                ));
173            }
174        }
175        let mut allowlist = Self::default();
176        for (key, fields) in [
177            ("remove", &["kind", "id"][..]),
178            ("rename", &["kind", "from", "to"][..]),
179        ] {
180            let Some(list) = root.get(key) else {
181                continue;
182            };
183            let items = list
184                .as_array()
185                .ok_or_else(|| fail(list.span(), "shape.type", format!("{key} is an array")))?;
186            for item in items {
187                closed(item, fields, &fail)?;
188                let mut values = Vec::with_capacity(fields.len());
189                for field in fields {
190                    let value = item.get(field).ok_or_else(|| {
191                        fail(
192                            item.span(),
193                            "shape.missing_key",
194                            format!("missing \"{field}\""),
195                        )
196                    })?;
197                    let text = value.as_str().ok_or_else(|| {
198                        fail(value.span(), "shape.type", format!("{field} is a string"))
199                    })?;
200                    values.push((text, value.span()));
201                }
202                let (kind_text, kind_span) = values[0];
203                let kind = Kind::parse(kind_text)
204                    .ok_or_else(|| fail(kind_span, "shape.range", kinds_message()))?;
205                for (id, span) in &values[1..] {
206                    if !kind.is_id(id) {
207                        return Err(fail(*span, "shape.identifier", id_form(kind)));
208                    }
209                }
210                let (from, from_span) = values[1];
211                let to = values.get(2).map(|(to, _)| (*to).to_owned());
212                if to.as_deref() == Some(from) {
213                    return Err(fail(
214                        item.span(),
215                        "shape.range",
216                        "a rename's from and to differ".into(),
217                    ));
218                }
219                let ids = allowlist.entries.entry(kind).or_default();
220                if ids.contains_key(from) {
221                    return Err(fail(
222                        from_span,
223                        "ref.duplicate",
224                        format!("{kind} {from} has two entries"),
225                    ));
226                }
227                ids.insert(from.to_owned(), to);
228            }
229        }
230        Ok(allowlist)
231    }
232
233    /// Whether the allowlist has no entries.
234    #[must_use]
235    pub fn is_empty(&self) -> bool {
236        self.entries.values().all(BTreeMap::is_empty)
237    }
238
239    /// Every entry, sorted by kind and old ID.
240    pub fn allowances(&self) -> impl Iterator<Item = Allowance> + '_ {
241        self.entries.iter().flat_map(|(kind, ids)| {
242            ids.iter().map(|(from, to)| match to {
243                None => Allowance::Remove {
244                    kind: *kind,
245                    id: from.clone(),
246                },
247                Some(to) => Allowance::Rename {
248                    kind: *kind,
249                    from: from.clone(),
250                    to: to.clone(),
251                },
252            })
253        })
254    }
255
256    /// The entry for old ID `id`.
257    pub(crate) fn get(&self, kind: Kind, id: &str) -> Option<Intent<'_>> {
258        self.entries
259            .get(&kind)?
260            .get(id)
261            .map(|to| to.as_deref().map_or(Intent::Remove, Intent::Rename))
262    }
263}
264
265/// Checks that `value` is an object with no keys but `keys`.
266fn closed(
267    value: &Value,
268    keys: &[&str],
269    fail: &impl Fn(Span, &'static str, String) -> AllowlistError,
270) -> Result<(), AllowlistError> {
271    let members = value
272        .as_object()
273        .ok_or_else(|| fail(value.span(), "shape.type", "expected an object".into()))?;
274    match members
275        .iter()
276        .find(|member| !keys.contains(&member.key.as_str()))
277    {
278        Some(member) => Err(fail(
279            member.key_span,
280            "shape.unknown_key",
281            format!("unknown key; expected {}", keys.join(", ")),
282        )),
283        None => Ok(()),
284    }
285}
286
287/// The message for a kind that is not one of [`Kind::ALL`], which it lists.
288fn kinds_message() -> String {
289    let names: Vec<&str> = Kind::ALL.iter().map(|kind| kind.as_str()).collect();
290    match names.split_last() {
291        Some((last, rest)) => format!("kind must be {} or {last}", rest.join(", ")),
292        None => "kind must be a known kind".to_owned(),
293    }
294}
295
296/// How IDs of `kind` are written.
297fn id_form(kind: Kind) -> String {
298    let form = match kind {
299        Kind::Definition => "<type>/<id>",
300        Kind::Object | Kind::Plane | Kind::RenderLayer => "an identifier",
301        Kind::Anchor | Kind::Stairwell | Kind::Zone => "<plane>/<id>",
302    };
303    let article = if kind.as_str().starts_with(['a', 'e', 'i', 'o', 'u']) {
304        "an"
305    } else {
306        "a"
307    };
308    format!(
309        "{article} {kind} ID is {form}, with identifiers matching ^[a-z0-9][a-z0-9_.-]{{0,127}}$"
310    )
311}