ocpi-tariffs 0.53.0

OCPI tariff calculations
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
//! Applying [`Edit`]s to the source of a parsed [`json::Document`].
//!
//! Producing the edits and agreeing to apply them are separate steps, which is what lets edits
//! drawn from different kinds of warning be resolved together. A `warning::Set<schema::Warning>`
//! and a `warning::Set<lint::tariff::Warning>` are different types, so no single call can take
//! both; [`Edit`] is the common form each reduces to, and a caller concatenates them before one
//! [`apply`].
//!
//! Applying each set in turn instead would resolve the second against a re-parsed document, where
//! the ids of the first no longer mean the same elements, so two edits landing on one element
//! could not be resolved against each other at all.
//!
//! [`apply`] resolves a whole set of edits against one document in a single pass. Resolving
//! them one at a time would be wrong for the same reason within a set: which span removes an
//! element depends on which of its siblings survive, so removals have to be resolved together.
//! See [`json::Document::removal_spans`].

#[cfg(test)]
mod test_apply;

#[cfg(test)]
mod test_edits;

#[cfg(test)]
mod test_json;

#[cfg(test)]
mod test_lint_warning;

#[cfg(test)]
mod test_price_invariant;

#[cfg(test)]
mod test_schema_warning;

use std::{
    collections::{BTreeMap, BTreeSet},
    fmt,
};

use crate::{json, lint, schema, string, warning, weekday};

/// The edits that resolve `warnings` against the document they were raised on.
///
/// A [`Fixable`] warning says what edit would resolve it; it does not decide whether that edit
/// is wanted. Filter the [`warning::Set`] before calling this to leave a fix out, with
/// `Set::remove_unexpected_fields` and its siblings.
///
/// The edits come back in document order, since a [`warning::Set`] is keyed by
/// [`json::ElemId`]. Pass them to [`apply`], which resolves how they interact.
pub fn edits<W: Fixable>(
    doc: &json::Document<'_>,
    warnings: &warning::Set<W>,
) -> Result<Vec<Edit>, Error> {
    let mut out: Vec<Edit> = Vec::new();

    for group in warnings {
        let (element, raised) = group.to_parts();
        let live = doc
            .element(element.id)
            .ok_or(Error::UnknownElement(element.id))?;

        for warning in raised {
            if let Some(edit) = warning.fix(live) {
                out.push(edit);
            }
        }
    }

    Ok(out)
}

/// A [`Warning`](crate::Warning) that a mechanical edit can resolve.
///
/// An implementation reports what edit would resolve the warning, not whether the edit should
/// be made. Whether a given fix is wanted is the caller's decision, so a caller that does not
/// want one filters the warning out of the set before calling [`edits`].
///
/// Sealed, because only this crate can build an [`Edit`]: an implementation outside it could
/// return nothing but `None`.
#[expect(
    private_bounds,
    reason = "`Sealed` is crate-private on purpose; that is what seals the trait"
)]
pub trait Fixable: crate::Warning + sealed::Sealed {
    /// The edit that resolves this warning at `element`, or `None` if nothing mechanical can
    /// resolve it.
    fn fix(&self, element: &json::Element<'_>) -> Option<Edit>;
}

/// Seals [`Fixable`] against implementations outside this crate.
pub(crate) mod sealed {
    /// The supertrait of [`Fixable`](super::Fixable) that only this crate can name, and so the
    /// only crate that can implement `Fixable`.
    pub(crate) trait Sealed {}
}

impl sealed::Sealed for schema::Warning {}

impl Fixable for schema::Warning {
    #[expect(
        clippy::match_same_arms,
        reason = "one arm per variant, so a new variant has to state whether it can be fixed"
    )]
    fn fix(&self, element: &json::Element<'_>) -> Option<Edit> {
        match self {
            // A `null` value has no semantic meaning in OCPI. The item can be removed.
            Self::NullField => Some(Edit::remove(element.id())),

            // The OCPI spec does not define this field so the fix feature can remove it.
            Self::UnexpectedField => Some(Edit::remove(element.id())),

            // A non-spec field is off-spec but used because we can infer what it should have been.
            // Removing it would change how the document prices, so there is no fix action.
            Self::NonSpecField => None,

            // Write the variant with the correct case.
            Self::IncorrectCase {
                expected,
                actual: _,
            } => Some(Edit::replace(element.id(), Json::string(expected))),

            // All the below issues require intervention from the author.
            Self::MissingField { name: _ }
            | Self::InvalidType {
                expected: _,
                actual: _,
            }
            | Self::StringTooLong { max: _, len: _ }
            | Self::InvalidValue {
                expected: _,
                actual: _,
            }
            | Self::Cardinality => None,
        }
    }
}

impl sealed::Sealed for lint::tariff::Warning {}

impl Fixable for lint::tariff::Warning {
    #[expect(
        clippy::match_same_arms,
        reason = "one arm per variant, so a new variant has to state whether it can be fixed"
    )]
    fn fix(&self, element: &json::Element<'_>) -> Option<Edit> {
        match self {
            // A list of all seven days matches every day, which is what leaving the list out
            // means, so the element matches the same days without it.
            Self::ContainsEntireWeek => Some(Edit::remove(element.id())),

            // Which days a list matches is a question of membership, so neither the order of
            // the days nor a repeat of one changes it.
            Self::DayOfWeekDuplicates | Self::DayOfWeekUnsorted => sorted_day_of_week(element),

            // An empty list matches no day at all and removing it would match every day, so
            // there is no edit that keeps the meaning. The author has to say which was meant.
            Self::DayOfWeekEmpty => None,

            // `23:59` and the `00:00` the spec asks for do not cover the same day, so
            // rewriting one to the other moves a boundary by a minute.
            Self::EndTimeIsNearEndOfDay => None,

            // Nothing mechanical resolves these. Two fields disagree and only the author knows
            // which of them is wrong, or the value is unusable and only the author knows what
            // it was meant to be. `Duration` is the one wrapped enum with no fixable variant:
            // a duration that does not parse, is not an int, or overflows is all it reports.
            Self::Duration(_)
            | Self::MinPriceIsGreaterThanMax
            | Self::StartDateTimeIsAfterEndDateTime => None,

            // TODO #406 Fixable as a removal of this element, but only when its value is exactly
            // `00:00`: a bound that is not there restricts nothing, so dropping a `00:00`
            // leaves the same window. `is_day_end` also accepts `23:59`, and dropping that
            // widens the window by a minute, which is `EndTimeIsNearEndOfDay`'s objection.
            // When both bounds are present only the one the warning sits on can go in a pass;
            // the other is raised again on the next, so the pair takes two fixing passes.
            Self::ContainsEntireDay => None,

            // TODO #405 Fixable at this element, not yet written. The warning is raised only
            // once the value has parsed as a valid alpha-3, so the `country::Code` behind it
            // already holds the alpha-2 to write in its place, and the lowering normalizes the
            // two strings to that same `Code` regardless. Needs a crate-visible alpha-3
            // lookup, because `Code::from_alpha_3` is reachable from `country` alone.
            Self::CpoCountryCodeShouldBeAlpha2 => None,

            // TODO #376 Fixable, but not by editing the element the warning sits on: an element
            // that can never match is discarded whole, which is the canonicalizer's job rather
            // than a local edit to one restriction.
            Self::MaxZeroNeverMatch | Self::NeverValid => None,

            // TODO #375 Fixable as a removal of this element: an object holding no
            // restriction fields restricts nothing. This is the same semantics as no object.
            // A document is usually left with an empty object by an earlier fix rather than
            // written that way, so resolving it takes a second pass over the document.
            Self::RestrictionsEmpty => None,

            // TODO #407 The lowering's own warnings, each wrapping an enum that mixes the two
            // cases. A lower-case country, currency or string is `IncorrectCase` and a
            // rewrite; so is a value carrying escape codes that decode. An invalid code, a
            // malformed escape or a number out of range is not. Resolving them means `Fixable`
            // reaching into the wrapped enum, since the wrapper is the wrong altitude for the
            // decision.
            Self::Country(_)
            | Self::Currency(_)
            | Self::DateTime(_)
            | Self::Money(_)
            | Self::Number(_)
            | Self::String(_) => None,
        }
    }
}

/// The edit that puts a `day_of_week` list in spec order and drops its repeats.
///
/// Returns `None` unless every item is a day this can name, so a list holding anything else is
/// left exactly as the author wrote it. The schema accepts a day in any case, and the match
/// here is exact, so a lower-case list is one of the lists that keeps its warning.
fn sorted_day_of_week(element: &json::Element<'_>) -> Option<Edit> {
    let items = element.value().as_array()?;
    let mut days: Vec<weekday::Weekday> = Vec::with_capacity(items.len());

    for item in items {
        let json::Value::String(raw) = item.value() else {
            return None;
        };

        days.push(weekday::Weekday::from_canonical(raw.as_unescaped_str())?);
    }

    days.sort_unstable();
    days.dedup();

    let names: Vec<&str> = days.iter().map(|day| day.canonical()).collect();

    Some(Edit::replace(element.id(), Json::string_array(&names)))
}

/// Apply `edits` to the source of `doc` and return the edited JSON.
///
/// The output is re-parsed before it is returned. Since every replacement payload is one JSON
/// value by construction, and a value is legal wherever another one was, this should not be able
/// to fail; it is kept as a backstop on the splicing, and on the payload builders in a release
/// build where their own assertion is compiled out.
///
/// Edits interact, and the rules are not symmetric:
///
/// | Case | Outcome |
/// |---|---|
/// | Two removals of the same element | Removed once |
/// | A removal of an element inside another removed element | The ancestor's span covers it |
/// | A replacement of an element that is also removed | The removal wins |
/// | A replacement of an element inside a removed element | The removal wins |
/// | Two replacements of the same element, same text | Replaced once |
/// | Two replacements of the same element, differing text | [`Error::Duplicate`] |
/// | A replacement of an element inside another replaced element | [`Error::Conflict`] |
/// | A removal of an element inside a replaced element | [`Error::Conflict`] |
///
/// A removal absorbs any edit inside it because the bytes go away either way, so nothing an
/// inner edit intended is lost. A replacement cannot absorb one: its payload was written
/// without knowledge of the inner edit, so applying it would discard that edit silently.
///
/// Two replacements asking for the same text are one edit proposed twice, not a disagreement:
/// a `day_of_week` list can be both unsorted and repeating, and the same sorted list resolves
/// either warning. Only replacements that disagree about the text are an error.
pub fn apply(doc: &json::Document<'_>, edits: &[Edit]) -> Result<String, Error> {
    let splices = resolve(doc, edits)?;
    let edited = splice(doc.source(), &splices)?;

    check_parses(&edited)?;

    Ok(edited)
}

/// A single change to one element of a document.
///
/// Opaque, and produced only by [`edits`]. Which edit resolves a warning is this crate's
/// decision; a caller's decision is *which warnings to resolve*, made by filtering the
/// [`warning::Set`] before calling [`edits`]. That is also the only layer at which the choice can
/// be expressed, since an `Edit` records what to change and not the warning that asked for it.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Edit(Change);

impl Edit {
    /// Remove the element from its parent, along with the separator that joined it to its
    /// siblings.
    pub(crate) fn remove(elem: json::ElemId) -> Self {
        Self(Change::Remove(elem))
    }

    /// Replace the element's value with `json`, leaving an object field's key in place.
    pub(crate) fn replace(elem: json::ElemId, json: Json) -> Self {
        Self(Change::Replace { elem, json })
    }
}

/// What an [`Edit`] does to its element.
#[derive(Clone, Debug, Eq, PartialEq)]
enum Change {
    /// Remove the element from its parent, along with the separator that joined it to its
    /// siblings.
    Remove(json::ElemId),

    /// Replace the element's value with `json`, leaving an object field's key in place.
    Replace {
        /// The element whose value is rewritten.
        elem: json::ElemId,

        /// The value taking its place.
        json: Json,
    },
}

/// One JSON value represented as a `String`.
///
/// The text is spliced in place of an element's own span, so it stands in for exactly one value:
/// it carries no field key, no comma, and no second value. Which value it is does not matter, as
/// any JSON value is a valid replacement from a JSON spec point-of-view.
///
/// There is no constructor that takes JSON source. Each one takes the content to write and builds
/// the text itself, escaping what it writes, so a `Json` is one valid JSON value by construction.
/// What that does *not* promise is that it is the right value for where it lands: it is not
/// checked against the OCPI schema for that position, so [`apply`] will happily write a number
/// where an array was.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct Json(String);

impl Json {
    /// A JSON array holding each of `values` as a JSON string.
    ///
    /// The quoting and escaping happen here, so a caller passes the string content it wants
    /// written rather than JSON source: a value holding a quote or a backslash arrives in the
    /// document as that same one character.
    pub(crate) fn string_array(values: &[&str]) -> Self {
        let mut json = String::from("[");

        for (index, value) in values.iter().enumerate() {
            if index > 0 {
                json.push_str(", ");
            }

            push_json_string(&mut json, value);
        }

        json.push(']');

        Self::built(json)
    }

    /// A JSON string.
    ///
    /// `value` is quoted and escaped, so a caller passes the string content it wants
    /// written rather than JSON source.
    pub(crate) fn string(value: &str) -> Self {
        let mut json = String::new();

        push_json_string(&mut json, value);

        Self::built(json)
    }

    /// The text this value will be written as, exactly as it was built.
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Take text built by a constructor above, holding it to being a single JSON value.
    ///
    /// Every constructor ends here, so the check covers all of them. It is a `debug_assert`
    /// rather than a returned error because the producer is always this crate. Text that is not
    /// one JSON value is a bug in the constructor that built it, not something a caller passed in
    /// or can act on. A release build still cannot write a corrupt document, since [`apply`]
    /// re-parses the whole result and reports [`Error::Internal`]; what the assertion adds is the
    /// producer's own stack rather than that after-the-fact report.
    fn built(text: String) -> Self {
        debug_assert!(
            is_one_json_value(&text),
            "`fix` built text that is not a single JSON value: `{text}`"
        );

        Self(text)
    }

    /// Take `text` verbatim, for tests needing a payload shape no constructor above builds.
    #[cfg(test)]
    fn raw(text: &str) -> Self {
        Self::built(text.to_owned())
    }
}

impl fmt::Display for Json {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

/// Write `value` into `json` as a quoted JSON string.
fn push_json_string(json: &mut String, value: &str) {
    json.push('"');

    for c in value.chars() {
        match c {
            '"' => json.push_str(r#"\""#),
            '\\' => json.push_str(r"\\"),
            '\n' => json.push_str(r"\n"),
            '\r' => json.push_str(r"\r"),
            '\t' => json.push_str(r"\t"),
            '\u{8}' => json.push_str(r"\b"),
            '\u{c}' => json.push_str(r"\f"),

            // JSON forbids a literal control character inside a string, and the ones above are
            // the only control characters with a shorthand, so the rest need the `\u` form.
            c if c < ' ' => {
                let code = u32::from(c);

                json.push_str(r"\u00");
                json.push(hex_digit(code >> 4));
                json.push(hex_digit(code & 0xf));
            }

            c => json.push(c),
        }
    }

    json.push('"');
}

/// The lower-case hex digit for `nibble`, which every caller here has already masked to one.
fn hex_digit(nibble: u32) -> char {
    char::from_digit(nibble, 16).unwrap_or('0')
}

/// Whether `text` is exactly one JSON value, which is what a replacement has room for.
fn is_one_json_value(text: &str) -> bool {
    let Ok(checked) = string::ReasonableLen::new(text) else {
        return false;
    };

    json::parse(checked).is_ok()
}

/// Why a set of [`Edit`]s could not be applied.
///
/// Most of these say the edits asked for something contradictory, or named an element that is
/// not there, and the caller can act on them. [`Error::Internal`] and [`Error::OverlappingSpans`]
/// are different in kind: no set of edits can ask for either, so reaching one is a bug in this
/// module rather than anything the caller did. They are returned rather than panicked on because
/// the caller is about to write the result over someone's tariff, and refusing to is better than
/// crashing in their process or, worse, handing back JSON that has been corrupted.
#[derive(Debug, Eq, PartialEq)]
pub enum Error {
    /// More than one replacement targets this element, and they disagree about the text.
    Duplicate(json::ElemId),

    /// Applying `outer` would silently discard the edit to `inner`, which sits inside it.
    Conflict {
        /// The replaced element.
        outer: json::ElemId,

        /// The element edited inside it.
        inner: json::ElemId,
    },

    /// The document has no element with this id.
    UnknownElement(json::ElemId),

    /// The removals could not be resolved to spans.
    Removal(json::RemovalError),

    /// Splicing produced source that is no longer valid JSON, which is a bug in this module.
    ///
    /// A replacement carries a payload that is one JSON value by construction, and a value is
    /// legal wherever another value was, so no payload should cause this. What is left is the
    /// removal spans and the splice itself, so reaching this means one of those is wrong - or, in
    /// a release build, that a payload builder is, since its own assertion is compiled out.
    Internal(json::Error),

    /// The spliced source exceeds the maximum size deemed reasonable.
    ///
    /// A caller can reach this: replacements longer than what they stand in for can take a
    /// document that was within the limit past it. This is the only size bound in the module, and
    /// it is on the whole output, since a bound on one payload would not catch that.
    OutputTooLarge,

    /// Two resolved spans overlap, so erasing both would corrupt the output. No set of edits
    /// should reach this; it means resolution itself is wrong.
    OverlappingSpans {
        /// The span that starts first.
        first: json::Span,

        /// The span that starts inside it.
        second: json::Span,
    },
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Duplicate(id) => {
                write!(f, "More than one edit replaces the element with id `{id}`.")
            }
            Self::Conflict { outer, inner } => write!(
                f,
                "Replacing the element with id `{outer}` would discard the edit to the element with id `{inner}` inside it."
            ),
            Self::UnknownElement(id) => write!(f, "The document has no element with id `{id}`."),
            Self::Removal(error) => write!(f, "{error}"),
            Self::Internal(error) => write!(
                f,
                "The edits spliced into JSON that does not parse, which is a bug in `fix`: {error}"
            ),
            Self::OutputTooLarge => write!(
                f,
                "The edited JSON exceeds the reasonable maximum `{} MB`.",
                string::ReasonableLen::FACTOR
            ),
            Self::OverlappingSpans { first, second } => write!(
                f,
                "The spans `{first:?}` and `{second:?}` overlap, so the edits can not both be applied."
            ),
        }
    }
}

impl std::error::Error for Error {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Removal(error) => Some(error),
            Self::Internal(error) => Some(error),
            Self::Duplicate(_)
            | Self::Conflict { outer: _, inner: _ }
            | Self::UnknownElement(_)
            | Self::OutputTooLarge
            | Self::OverlappingSpans {
                first: _,
                second: _,
            } => None,
        }
    }
}

/// One resolved edit: the span of source it covers and the text that takes its place.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Splice<'edit> {
    span: json::Span,

    /// Empty for a removal.
    text: &'edit str,
}

/// A replacement that has been resolved to the span it rewrites.
#[derive(Clone, Copy, Debug)]
struct Replacement<'edit> {
    elem: json::ElemId,
    span: json::Span,
    text: &'edit str,
}

/// Resolve `edits` into the spans to rewrite, sorted by start offset.
fn resolve<'edit>(
    doc: &json::Document<'_>,
    edits: &'edit [Edit],
) -> Result<Vec<Splice<'edit>>, Error> {
    let mut removals: BTreeSet<json::ElemId> = BTreeSet::new();
    let mut replacements: BTreeMap<json::ElemId, &'edit str> = BTreeMap::new();

    for edit in edits {
        match &edit.0 {
            Change::Remove(id) => {
                removals.insert(*id);
            }
            Change::Replace { elem, json } => {
                let earlier = replacements.insert(*elem, json.as_str());

                if earlier.is_some_and(|earlier| earlier != json.as_str()) {
                    return Err(Error::Duplicate(*elem));
                }
            }
        }
    }

    let removed = doc.removal_spans(&removals).map_err(Error::Removal)?;
    let replaced = resolve_replacements(doc, &replacements, &removals, &removed)?;

    check_no_removal_inside_a_replacement(doc, &removals, &replaced)?;

    let mut splices: Vec<Splice<'edit>> = Vec::new();

    for span in removed {
        splices.push(Splice { span, text: "" });
    }

    for replacement in replaced {
        splices.push(Splice {
            span: replacement.span,
            text: replacement.text,
        });
    }

    splices.sort_by_key(|splice| splice.span);

    Ok(splices)
}

/// Resolve each replacement to its span, dropping the ones a removal absorbs.
fn resolve_replacements<'edit>(
    doc: &json::Document<'_>,
    replacements: &BTreeMap<json::ElemId, &'edit str>,
    removals: &BTreeSet<json::ElemId>,
    removed: &[json::Span],
) -> Result<Vec<Replacement<'edit>>, Error> {
    let mut resolved: Vec<Replacement<'edit>> = Vec::new();

    for (elem, text) in replacements {
        if removals.contains(elem) {
            continue;
        }

        let element = doc.element(*elem).ok_or(Error::UnknownElement(*elem))?;
        let span = element.span();

        if removed.iter().any(|erased| contains(*erased, span)) {
            continue;
        }

        resolved.push(Replacement {
            elem: *elem,
            span,
            text,
        });
    }

    resolved.sort_by_key(|replacement| replacement.span);

    check_no_nested_replacement(&resolved)?;

    Ok(resolved)
}

/// Reject a replacement that sits inside another replacement.
///
/// Element spans nest or are disjoint, and no two elements share a start offset, so sorting
/// by span puts an enclosing replacement before the one it encloses.
fn check_no_nested_replacement(replaced: &[Replacement<'_>]) -> Result<(), Error> {
    let mut outer: Option<Replacement<'_>> = None;

    for replacement in replaced {
        if let Some(open) = outer {
            if replacement.span.start < open.span.end {
                return Err(Error::Conflict {
                    outer: open.elem,
                    inner: replacement.elem,
                });
            }
        }

        outer = Some(*replacement);
    }

    Ok(())
}

/// Reject a removal that sits inside a replacement.
fn check_no_removal_inside_a_replacement(
    doc: &json::Document<'_>,
    removals: &BTreeSet<json::ElemId>,
    replaced: &[Replacement<'_>],
) -> Result<(), Error> {
    for id in removals {
        let element = doc.element(*id).ok_or(Error::UnknownElement(*id))?;
        let span = element.span();
        let enclosing = replaced
            .iter()
            .find(|replacement| contains(replacement.span, span));

        if let Some(replacement) = enclosing {
            return Err(Error::Conflict {
                outer: replacement.elem,
                inner: *id,
            });
        }
    }

    Ok(())
}

/// Rewrite `source` by replacing each span with its text.
fn splice(source: &str, splices: &[Splice<'_>]) -> Result<String, Error> {
    let mut out = String::with_capacity(source.len());
    let mut pos: u32 = 0;

    for splice in splices {
        if splice.span.start < pos {
            let previous = splices
                .iter()
                .find(|earlier| earlier.span.end == pos)
                .map(|earlier| earlier.span)
                .unwrap_or_default();

            return Err(Error::OverlappingSpans {
                first: previous,
                second: splice.span,
            });
        }

        out.push_str(slice(source, pos, splice.span.start));
        out.push_str(splice.text);
        pos = splice.span.end;
    }

    out.push_str(slice(source, pos, source_len(source)));

    Ok(out)
}

/// Return true if `outer` covers every byte of `inner`.
fn contains(outer: json::Span, inner: json::Span) -> bool {
    inner.start >= outer.start && inner.end <= outer.end
}

/// Re-parse edited JSON, so a corrupted splice is reported rather than written.
fn check_parses(json: &str) -> Result<(), Error> {
    let json = string::ReasonableLen::new(json).map_err(|_e| Error::OutputTooLarge)?;

    json::parse(json).map_err(Error::Internal)?;

    Ok(())
}

/// The byte range `start..end` of `source`.
///
/// Every span comes from the parser that read `source`, so it always lands on a character
/// boundary; an out of range span yields an empty `&str` rather than a panic.
fn slice(source: &str, start: u32, end: u32) -> &str {
    let Ok(start) = usize::try_from(start) else {
        return "";
    };
    let Ok(end) = usize::try_from(end) else {
        return "";
    };

    source.get(start..end).unwrap_or("")
}

/// The length of `source` as a span offset.
fn source_len(source: &str) -> u32 {
    u32::try_from(source.len()).unwrap_or(u32::MAX)
}