Skip to main content

weavatrix_edit/
envelope.rs

1//! Hand-written wire codecs for the frozen `weavatrix.edit-plan.v1` envelope.
2//!
3//! [`TextEdit`], [`FileEdit`], and [`EditPlan`] previously derived serde with
4//! `#[serde(flatten)]` extension maps. The derive read declared members
5//! directly and buffered only undeclared ones into serde's private `Content`
6//! tree before a second `FlatMapDeserializer` pass; these codecs read every
7//! member exactly once instead. Measured on its own that is close to a wash
8//! (see `docs/decoder-comparison.md`) — the point of writing them by hand is
9//! that `flatten` cannot express "decode this envelope and skip the undeclared
10//! members", which is where the actual cost lives.
11//!
12//! The map visitors are therefore generic over an [`ExtensionPolicy`]: the
13//! capturing decode and the declared-only decode behind
14//! [`DeclaredEditPlan`] share one implementation of field matching, duplicate
15//! detection, and missing-field reporting, and cannot drift apart.
16//!
17//! Every observable behaviour of the derive is preserved: the same accepted
18//! documents, the same declared-field-then-`BTreeMap`-order serialized bytes,
19//! and the same `duplicate field`, `missing field`, and `invalid type` messages
20//! at the same input positions. `tests/envelope_wire.rs` pins this against an
21//! independent restatement of the original derives on two serde drivers.
22
23use core::fmt;
24use core::marker::PhantomData;
25use std::collections::BTreeMap;
26
27use blazingly_json::Value;
28use serde::de::{DeserializeSeed, Deserializer, IgnoredAny, MapAccess, SeqAccess, Visitor};
29use serde::ser::SerializeMap;
30use serde::{Deserialize, Serialize, Serializer, de};
31
32use crate::{
33    model::{Completeness, EditPlan, FileEdit, TextEdit},
34    provenance::Provenance,
35};
36
37/// Declared member names of a [`TextEdit`], in serialization order.
38pub(crate) const TEXT_EDIT_FIELDS: [&str; 7] = [
39    "startLine",
40    "startChar",
41    "endLine",
42    "endChar",
43    "before",
44    "after",
45    "provenance",
46];
47
48/// Declared member names of a [`FileEdit`], in serialization order.
49pub(crate) const FILE_EDIT_FIELDS: [&str; 3] = ["path", "sha256", "edits"];
50
51/// Declared member names of an [`EditPlan`], in serialization order.
52pub(crate) const EDIT_PLAN_FIELDS: [&str; 4] =
53    ["schemaVersion", "operation", "files", "completeness"];
54
55// ---------------------------------------------------------------------------
56// Extension policy
57// ---------------------------------------------------------------------------
58
59/// How one decode pass treats members that are not declared by the envelope.
60///
61/// Both policies match declared members identically. They diverge on undeclared
62/// content: [`Capture`] builds a value and fails on anything the value model
63/// cannot represent, while [`Discard`] never inspects it. See
64/// [`DeclaredEditPlan`] for what that means for callers.
65trait ExtensionPolicy: Copy {
66    /// Retained key representation for an undeclared member.
67    type Key;
68    /// Accumulator threaded through one object.
69    type Sink;
70
71    fn key(name: &str) -> Self::Key;
72
73    fn sink() -> Self::Sink;
74
75    /// Consumes the pending value of an undeclared member.
76    fn absorb<'de, A>(sink: &mut Self::Sink, key: Self::Key, map: &mut A) -> Result<(), A::Error>
77    where
78        A: MapAccess<'de>;
79
80    fn finish(sink: Self::Sink) -> BTreeMap<String, Value>;
81}
82
83/// Retains every undeclared member as an owned JSON value.
84#[derive(Clone, Copy)]
85struct Capture;
86
87impl ExtensionPolicy for Capture {
88    type Key = String;
89    type Sink = BTreeMap<String, Value>;
90
91    fn key(name: &str) -> String {
92        name.to_owned()
93    }
94
95    fn sink() -> Self::Sink {
96        BTreeMap::new()
97    }
98
99    fn absorb<'de, A>(sink: &mut Self::Sink, key: String, map: &mut A) -> Result<(), A::Error>
100    where
101        A: MapAccess<'de>,
102    {
103        // Last duplicate wins, matching the derive's `FlatMapDeserializer` pass.
104        sink.insert(key, map.next_value()?);
105        Ok(())
106    }
107
108    fn finish(sink: Self::Sink) -> BTreeMap<String, Value> {
109        sink
110    }
111}
112
113/// Skips every undeclared member without allocating a key or a value tree.
114#[derive(Clone, Copy)]
115struct Discard;
116
117impl ExtensionPolicy for Discard {
118    type Key = ();
119    type Sink = ();
120
121    fn key(_name: &str) {}
122
123    fn sink() -> Self::Sink {}
124
125    fn absorb<'de, A>(_sink: &mut (), _key: (), map: &mut A) -> Result<(), A::Error>
126    where
127        A: MapAccess<'de>,
128    {
129        map.next_value::<IgnoredAny>()?;
130        Ok(())
131    }
132
133    fn finish((): Self::Sink) -> BTreeMap<String, Value> {
134        BTreeMap::new()
135    }
136}
137
138// ---------------------------------------------------------------------------
139// Member names
140// ---------------------------------------------------------------------------
141
142enum Field<K> {
143    /// Index into the declaring struct's field-name table.
144    Known(usize),
145    Unknown(K),
146}
147
148/// Resolves a member name to a declared field index.
149///
150/// Each implementation is a `match` over string literals, not a scan of a
151/// runtime slice, so the compiler lowers it to a length switch plus at most a
152/// couple of comparisons -- the same shape the derive generated.
153trait FieldTable {
154    fn lookup(name: &str) -> Option<usize>;
155}
156
157struct TextEditFields;
158
159impl FieldTable for TextEditFields {
160    fn lookup(name: &str) -> Option<usize> {
161        Some(match name {
162            "startLine" => 0,
163            "startChar" => 1,
164            "endLine" => 2,
165            "endChar" => 3,
166            "before" => 4,
167            "after" => 5,
168            "provenance" => 6,
169            _ => return None,
170        })
171    }
172}
173
174struct FileEditFields;
175
176impl FieldTable for FileEditFields {
177    fn lookup(name: &str) -> Option<usize> {
178        Some(match name {
179            "path" => 0,
180            "sha256" => 1,
181            "edits" => 2,
182            _ => return None,
183        })
184    }
185}
186
187struct EditPlanFields;
188
189impl FieldTable for EditPlanFields {
190    fn lookup(name: &str) -> Option<usize> {
191        Some(match name {
192            "schemaVersion" => 0,
193            "operation" => 1,
194            "files" => 2,
195            "completeness" => 3,
196            _ => return None,
197        })
198    }
199}
200
201struct FieldSeed<P, T>(PhantomData<(P, T)>);
202
203impl<P, T> Clone for FieldSeed<P, T> {
204    fn clone(&self) -> Self {
205        *self
206    }
207}
208
209impl<P, T> Copy for FieldSeed<P, T> {}
210
211impl<P, T> FieldSeed<P, T> {
212    const fn new() -> Self {
213        Self(PhantomData)
214    }
215}
216
217impl<'de, P: ExtensionPolicy, T: FieldTable> DeserializeSeed<'de> for FieldSeed<P, T> {
218    type Value = Field<P::Key>;
219
220    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
221    where
222        D: Deserializer<'de>,
223    {
224        deserializer.deserialize_identifier(self)
225    }
226}
227
228impl<P: ExtensionPolicy, T: FieldTable> Visitor<'_> for FieldSeed<P, T> {
229    type Value = Field<P::Key>;
230
231    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
232        formatter.write_str("field identifier")
233    }
234
235    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
236    where
237        E: de::Error,
238    {
239        // `visit_borrowed_str` and `visit_string` forward here by default, so an
240        // escaped spelling of a declared name still binds to that field.
241        Ok(match T::lookup(value) {
242            Some(index) => Field::Known(index),
243            None => Field::Unknown(P::key(value)),
244        })
245    }
246}
247
248// ---------------------------------------------------------------------------
249// Sequences of seeded elements
250// ---------------------------------------------------------------------------
251
252/// Mirrors `Vec<T>`'s own codec, threading an [`ExtensionPolicy`] into elements.
253struct VecSeed<S>(S);
254
255impl<'de, S> DeserializeSeed<'de> for VecSeed<S>
256where
257    S: DeserializeSeed<'de> + Copy,
258{
259    type Value = Vec<S::Value>;
260
261    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
262    where
263        D: Deserializer<'de>,
264    {
265        deserializer.deserialize_seq(self)
266    }
267}
268
269impl<'de, S> Visitor<'de> for VecSeed<S>
270where
271    S: DeserializeSeed<'de> + Copy,
272{
273    type Value = Vec<S::Value>;
274
275    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
276        formatter.write_str("a sequence")
277    }
278
279    fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
280    where
281        A: SeqAccess<'de>,
282    {
283        let mut items = Vec::with_capacity(cautious_capacity::<S::Value>(seq.size_hint()));
284        while let Some(item) = seq.next_element_seed(self.0)? {
285            items.push(item);
286        }
287        Ok(items)
288    }
289}
290
291/// Bounds a hinted preallocation the way serde's own sequence codecs do, so a
292/// hostile length hint cannot reserve unbounded memory before any element is
293/// read.
294fn cautious_capacity<T>(hint: Option<usize>) -> usize {
295    const MAX_PREALLOCATED_BYTES: usize = 1024 * 1024;
296    MAX_PREALLOCATED_BYTES
297        .checked_div(size_of::<T>())
298        .map_or(0, |ceiling| hint.unwrap_or(0).min(ceiling))
299}
300
301// ---------------------------------------------------------------------------
302// TextEdit
303// ---------------------------------------------------------------------------
304
305struct TextEditSeed<P>(PhantomData<P>);
306
307impl<P> Clone for TextEditSeed<P> {
308    fn clone(&self) -> Self {
309        *self
310    }
311}
312
313impl<P> Copy for TextEditSeed<P> {}
314
315impl<P> TextEditSeed<P> {
316    const fn new() -> Self {
317        Self(PhantomData)
318    }
319}
320
321impl<'de, P: ExtensionPolicy> DeserializeSeed<'de> for TextEditSeed<P> {
322    type Value = TextEdit;
323
324    fn deserialize<D>(self, deserializer: D) -> Result<TextEdit, D::Error>
325    where
326        D: Deserializer<'de>,
327    {
328        // `deserialize_map`, not `deserialize_struct`: the derive this replaces
329        // also used the map entry point, and a driver that accepts a positional
330        // sequence for a struct must keep rejecting one here.
331        deserializer.deserialize_map(self)
332    }
333}
334
335impl<'de, P: ExtensionPolicy> Visitor<'de> for TextEditSeed<P> {
336    type Value = TextEdit;
337
338    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
339        formatter.write_str("struct TextEdit")
340    }
341
342    fn visit_map<A>(self, mut map: A) -> Result<TextEdit, A::Error>
343    where
344        A: MapAccess<'de>,
345    {
346        let mut start_line: Option<u32> = None;
347        let mut start_char: Option<u32> = None;
348        let mut end_line: Option<u32> = None;
349        let mut end_char: Option<u32> = None;
350        let mut before: Option<String> = None;
351        let mut after: Option<String> = None;
352        let mut provenance: Option<Provenance> = None;
353        let mut sink = P::sink();
354
355        while let Some(field) = map.next_key_seed(FieldSeed::<P, TextEditFields>::new())? {
356            match field {
357                Field::Known(0) => {
358                    if start_line.is_some() {
359                        return Err(de::Error::duplicate_field("startLine"));
360                    }
361                    start_line = Some(map.next_value()?);
362                }
363                Field::Known(1) => {
364                    if start_char.is_some() {
365                        return Err(de::Error::duplicate_field("startChar"));
366                    }
367                    start_char = Some(map.next_value()?);
368                }
369                Field::Known(2) => {
370                    if end_line.is_some() {
371                        return Err(de::Error::duplicate_field("endLine"));
372                    }
373                    end_line = Some(map.next_value()?);
374                }
375                Field::Known(3) => {
376                    if end_char.is_some() {
377                        return Err(de::Error::duplicate_field("endChar"));
378                    }
379                    end_char = Some(map.next_value()?);
380                }
381                Field::Known(4) => {
382                    if before.is_some() {
383                        return Err(de::Error::duplicate_field("before"));
384                    }
385                    before = Some(map.next_value()?);
386                }
387                Field::Known(5) => {
388                    if after.is_some() {
389                        return Err(de::Error::duplicate_field("after"));
390                    }
391                    after = Some(map.next_value()?);
392                }
393                // The final declared index; the table has no further entries.
394                Field::Known(_) => {
395                    if provenance.is_some() {
396                        return Err(de::Error::duplicate_field("provenance"));
397                    }
398                    provenance = Some(map.next_value()?);
399                }
400                Field::Unknown(key) => P::absorb(&mut sink, key, &mut map)?,
401            }
402        }
403
404        Ok(TextEdit {
405            start_line: start_line.ok_or_else(|| de::Error::missing_field("startLine"))?,
406            start_char: start_char.ok_or_else(|| de::Error::missing_field("startChar"))?,
407            end_line: end_line.ok_or_else(|| de::Error::missing_field("endLine"))?,
408            end_char: end_char.ok_or_else(|| de::Error::missing_field("endChar"))?,
409            before: before.ok_or_else(|| de::Error::missing_field("before"))?,
410            after: after.ok_or_else(|| de::Error::missing_field("after"))?,
411            provenance: provenance.ok_or_else(|| de::Error::missing_field("provenance"))?,
412            extensions: P::finish(sink),
413        })
414    }
415}
416
417impl<'de> Deserialize<'de> for TextEdit {
418    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
419    where
420        D: Deserializer<'de>,
421    {
422        TextEditSeed::<Capture>::new().deserialize(deserializer)
423    }
424}
425
426impl Serialize for TextEdit {
427    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
428    where
429        S: Serializer,
430    {
431        // A map of unknown length, exactly as the flattened derive emitted, so
432        // both compact and pretty output stay byte-identical.
433        let mut map = serializer.serialize_map(None)?;
434        map.serialize_entry(TEXT_EDIT_FIELDS[0], &self.start_line)?;
435        map.serialize_entry(TEXT_EDIT_FIELDS[1], &self.start_char)?;
436        map.serialize_entry(TEXT_EDIT_FIELDS[2], &self.end_line)?;
437        map.serialize_entry(TEXT_EDIT_FIELDS[3], &self.end_char)?;
438        map.serialize_entry(TEXT_EDIT_FIELDS[4], &self.before)?;
439        map.serialize_entry(TEXT_EDIT_FIELDS[5], &self.after)?;
440        map.serialize_entry(TEXT_EDIT_FIELDS[6], &self.provenance)?;
441        serialize_extensions(&mut map, &self.extensions)?;
442        map.end()
443    }
444}
445
446// ---------------------------------------------------------------------------
447// FileEdit
448// ---------------------------------------------------------------------------
449
450struct FileEditSeed<P>(PhantomData<P>);
451
452impl<P> Clone for FileEditSeed<P> {
453    fn clone(&self) -> Self {
454        *self
455    }
456}
457
458impl<P> Copy for FileEditSeed<P> {}
459
460impl<P> FileEditSeed<P> {
461    const fn new() -> Self {
462        Self(PhantomData)
463    }
464}
465
466impl<'de, P: ExtensionPolicy> DeserializeSeed<'de> for FileEditSeed<P> {
467    type Value = FileEdit;
468
469    fn deserialize<D>(self, deserializer: D) -> Result<FileEdit, D::Error>
470    where
471        D: Deserializer<'de>,
472    {
473        deserializer.deserialize_map(self)
474    }
475}
476
477impl<'de, P: ExtensionPolicy> Visitor<'de> for FileEditSeed<P> {
478    type Value = FileEdit;
479
480    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
481        formatter.write_str("struct FileEdit")
482    }
483
484    fn visit_map<A>(self, mut map: A) -> Result<FileEdit, A::Error>
485    where
486        A: MapAccess<'de>,
487    {
488        let mut path: Option<String> = None;
489        let mut sha256: Option<String> = None;
490        let mut edits: Option<Vec<TextEdit>> = None;
491        let mut sink = P::sink();
492
493        while let Some(field) = map.next_key_seed(FieldSeed::<P, FileEditFields>::new())? {
494            match field {
495                Field::Known(0) => {
496                    if path.is_some() {
497                        return Err(de::Error::duplicate_field("path"));
498                    }
499                    path = Some(map.next_value()?);
500                }
501                Field::Known(1) => {
502                    if sha256.is_some() {
503                        return Err(de::Error::duplicate_field("sha256"));
504                    }
505                    sha256 = Some(map.next_value()?);
506                }
507                Field::Known(_) => {
508                    if edits.is_some() {
509                        return Err(de::Error::duplicate_field("edits"));
510                    }
511                    edits = Some(map.next_value_seed(VecSeed(TextEditSeed::<P>::new()))?);
512                }
513                Field::Unknown(key) => P::absorb(&mut sink, key, &mut map)?,
514            }
515        }
516
517        Ok(FileEdit {
518            path: path.ok_or_else(|| de::Error::missing_field("path"))?,
519            sha256: sha256.ok_or_else(|| de::Error::missing_field("sha256"))?,
520            edits: edits.ok_or_else(|| de::Error::missing_field("edits"))?,
521            extensions: P::finish(sink),
522        })
523    }
524}
525
526impl<'de> Deserialize<'de> for FileEdit {
527    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
528    where
529        D: Deserializer<'de>,
530    {
531        FileEditSeed::<Capture>::new().deserialize(deserializer)
532    }
533}
534
535impl Serialize for FileEdit {
536    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
537    where
538        S: Serializer,
539    {
540        let mut map = serializer.serialize_map(None)?;
541        map.serialize_entry(FILE_EDIT_FIELDS[0], &self.path)?;
542        map.serialize_entry(FILE_EDIT_FIELDS[1], &self.sha256)?;
543        map.serialize_entry(FILE_EDIT_FIELDS[2], &self.edits)?;
544        serialize_extensions(&mut map, &self.extensions)?;
545        map.end()
546    }
547}
548
549// ---------------------------------------------------------------------------
550// EditPlan
551// ---------------------------------------------------------------------------
552
553struct EditPlanSeed<P>(PhantomData<P>);
554
555impl<P> Clone for EditPlanSeed<P> {
556    fn clone(&self) -> Self {
557        *self
558    }
559}
560
561impl<P> Copy for EditPlanSeed<P> {}
562
563impl<P> EditPlanSeed<P> {
564    const fn new() -> Self {
565        Self(PhantomData)
566    }
567}
568
569impl<'de, P: ExtensionPolicy> DeserializeSeed<'de> for EditPlanSeed<P> {
570    type Value = EditPlan;
571
572    fn deserialize<D>(self, deserializer: D) -> Result<EditPlan, D::Error>
573    where
574        D: Deserializer<'de>,
575    {
576        deserializer.deserialize_map(self)
577    }
578}
579
580impl<'de, P: ExtensionPolicy> Visitor<'de> for EditPlanSeed<P> {
581    type Value = EditPlan;
582
583    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
584        formatter.write_str("struct EditPlan")
585    }
586
587    fn visit_map<A>(self, mut map: A) -> Result<EditPlan, A::Error>
588    where
589        A: MapAccess<'de>,
590    {
591        let mut schema_version: Option<String> = None;
592        let mut operation: Option<String> = None;
593        let mut files: Option<Vec<FileEdit>> = None;
594        // The outer `Option` records presence, so an explicit `null` still
595        // counts as a occupied member and a repeat is a duplicate field.
596        let mut completeness: Option<Option<Completeness>> = None;
597        let mut sink = P::sink();
598
599        while let Some(field) = map.next_key_seed(FieldSeed::<P, EditPlanFields>::new())? {
600            match field {
601                Field::Known(0) => {
602                    if schema_version.is_some() {
603                        return Err(de::Error::duplicate_field("schemaVersion"));
604                    }
605                    schema_version = Some(map.next_value()?);
606                }
607                Field::Known(1) => {
608                    if operation.is_some() {
609                        return Err(de::Error::duplicate_field("operation"));
610                    }
611                    operation = Some(map.next_value()?);
612                }
613                Field::Known(2) => {
614                    if files.is_some() {
615                        return Err(de::Error::duplicate_field("files"));
616                    }
617                    files = Some(map.next_value_seed(VecSeed(FileEditSeed::<P>::new()))?);
618                }
619                Field::Known(_) => {
620                    if completeness.is_some() {
621                        return Err(de::Error::duplicate_field("completeness"));
622                    }
623                    completeness = Some(map.next_value()?);
624                }
625                Field::Unknown(key) => P::absorb(&mut sink, key, &mut map)?,
626            }
627        }
628
629        Ok(EditPlan {
630            schema_version: schema_version
631                .ok_or_else(|| de::Error::missing_field("schemaVersion"))?,
632            operation: operation.ok_or_else(|| de::Error::missing_field("operation"))?,
633            files: files.ok_or_else(|| de::Error::missing_field("files"))?,
634            completeness: completeness.unwrap_or_default(),
635            extensions: P::finish(sink),
636        })
637    }
638}
639
640impl<'de> Deserialize<'de> for EditPlan {
641    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
642    where
643        D: Deserializer<'de>,
644    {
645        EditPlanSeed::<Capture>::new().deserialize(deserializer)
646    }
647}
648
649impl Serialize for EditPlan {
650    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
651    where
652        S: Serializer,
653    {
654        let mut map = serializer.serialize_map(None)?;
655        map.serialize_entry(EDIT_PLAN_FIELDS[0], &self.schema_version)?;
656        map.serialize_entry(EDIT_PLAN_FIELDS[1], &self.operation)?;
657        map.serialize_entry(EDIT_PLAN_FIELDS[2], &self.files)?;
658        if self.completeness.is_some() {
659            map.serialize_entry(EDIT_PLAN_FIELDS[3], &self.completeness)?;
660        }
661        serialize_extensions(&mut map, &self.extensions)?;
662        map.end()
663    }
664}
665
666/// Emits extension members in `BTreeMap` order, after the declared members, so
667/// an extension key that shadows a declared name produces the duplicate JSON
668/// key the flattened derive produced.
669fn serialize_extensions<M>(
670    map: &mut M,
671    extensions: &BTreeMap<String, Value>,
672) -> Result<(), M::Error>
673where
674    M: SerializeMap,
675{
676    for (key, value) in extensions {
677        map.serialize_entry(key, value)?;
678    }
679    Ok(())
680}
681
682// ---------------------------------------------------------------------------
683// Declared-only decode
684// ---------------------------------------------------------------------------
685
686/// An [`EditPlan`] decoded without materializing any extension member.
687///
688/// Decoding an envelope through [`EditPlan`] retains every undeclared member as
689/// an owned JSON value at all three levels. That is required to round-trip a
690/// plan, and it is the dominant cost of decoding a large multi-file plan. A
691/// consumer that only validates or applies a plan never reads those values and
692/// should not pay for them.
693///
694/// This wrapper recovers identical declared data and reports declared-member
695/// errors identically, but skips undeclared members structurally instead of
696/// building a key and a value tree for each one. The [`EditPlan`] it yields has
697/// empty `extensions` at every level.
698///
699/// # It is not an input-rejection gate
700///
701/// The two decodes do **not** accept the same documents. Capturing decode
702/// materializes each undeclared member into a value, so a member the value
703/// model cannot represent — a number outside the representable range, a lone
704/// surrogate escape, nesting past the driver's depth limit — fails the whole
705/// decode. This wrapper consumes that member without inspecting it and
706/// succeeds. The divergence is confined to content this path never looks at,
707/// and `tests/declared_divergence.rs` pins it.
708///
709/// Use it as a terminal consumer of declared data. Do not use it as the gate
710/// that rejects malformed input: [`PlanLimits`](crate::PlanLimits) does not
711/// bound extension payloads either, so a caller that must constrain undeclared
712/// content has to decode through [`EditPlan`] and enforce its own budget.
713///
714/// # Extensions are dropped, not hidden
715///
716/// The recovered plan is **not** round-trippable: re-serializing it emits only
717/// declared members. Decode through [`EditPlan`] whenever the extension members
718/// must survive. Validation is unaffected either way — a reserved member name
719/// can never reach an extension map through the wire, because a JSON member
720/// spelled like a declared field always binds to that field.
721///
722/// # Examples
723///
724/// ```
725/// use weavatrix_edit::DeclaredEditPlan;
726///
727/// let json = r#"{
728///     "schemaVersion": "weavatrix.edit-plan.v1",
729///     "operation": "rename_symbol",
730///     "createdAt": "2026-08-01T12:00:00Z",
731///     "files": [{
732///         "path": "src/user.ts",
733///         "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
734///         "language": "typescript",
735///         "edits": [{
736///             "startLine": 10, "startChar": 8, "endLine": 10, "endChar": 15,
737///             "before": "getUser", "after": "getCustomer", "provenance": "EXACT_LSP"
738///         }]
739///     }]
740/// }"#;
741///
742/// let declared: DeclaredEditPlan = blazingly_json::from_str(json)?;
743/// let plan = declared.into_plan();
744/// assert!(plan.validate().is_ok());
745/// assert!(plan.extensions.is_empty());
746/// assert!(plan.files[0].extensions.is_empty());
747/// # Ok::<(), blazingly_json::Error>(())
748/// ```
749#[derive(Clone, Debug, PartialEq)]
750pub struct DeclaredEditPlan(EditPlan);
751
752impl DeclaredEditPlan {
753    /// Borrows the recovered plan.
754    #[must_use]
755    pub const fn plan(&self) -> &EditPlan {
756        &self.0
757    }
758
759    /// Takes the recovered plan, whose extension maps are all empty.
760    #[must_use]
761    pub fn into_plan(self) -> EditPlan {
762        self.0
763    }
764}
765
766impl AsRef<EditPlan> for DeclaredEditPlan {
767    fn as_ref(&self) -> &EditPlan {
768        &self.0
769    }
770}
771
772impl From<DeclaredEditPlan> for EditPlan {
773    fn from(declared: DeclaredEditPlan) -> Self {
774        declared.0
775    }
776}
777
778impl<'de> Deserialize<'de> for DeclaredEditPlan {
779    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
780    where
781        D: Deserializer<'de>,
782    {
783        EditPlanSeed::<Discard>::new()
784            .deserialize(deserializer)
785            .map(Self)
786    }
787}
788
789#[cfg(test)]
790mod tests {
791    use super::{EDIT_PLAN_FIELDS, FILE_EDIT_FIELDS, TEXT_EDIT_FIELDS};
792    use crate::validation::FILE_EDIT_RESERVED_EXTENSION_KEYS;
793
794    #[test]
795    fn reserved_extension_keys_track_the_wire_field_names() {
796        // Validation rejects an extension key that shadows a declared member.
797        // That list must be the wire field list, or a future field rename would
798        // silently open a collision.
799        assert_eq!(FILE_EDIT_RESERVED_EXTENSION_KEYS, FILE_EDIT_FIELDS);
800        assert_eq!(FILE_EDIT_FIELDS.len(), 3);
801        assert_eq!(TEXT_EDIT_FIELDS.len(), 7);
802        assert_eq!(EDIT_PLAN_FIELDS.len(), 4);
803    }
804}