Skip to main content

weavatrix_rust_refactor/
plan.rs

1//! Building a `weavatrix.edit-plan.v1` envelope.
2//!
3//! Every planner emits the same shape, so it is built in one place. The alternative — each
4//! engine assembling its own JSON — is how one of them ends up omitting a hash or spelling a
5//! field differently, and the applier would refuse it long after the mistake was made.
6
7use blazingly_json::{Value, json};
8use std::collections::BTreeMap;
9
10/// The hash the applier will re-check before writing.
11#[must_use]
12pub fn sha256_of(text: &str) -> String {
13    weavatrix_worktree::Sha256Hash::compute(text.as_bytes()).to_string()
14}
15
16/// Accumulates files and their edits into one envelope.
17pub struct PlanBuilder {
18    operation: String,
19    files: Vec<(String, String, Vec<Value>)>,
20    index: BTreeMap<String, usize>,
21}
22
23impl PlanBuilder {
24    /// Starts a plan for one operation.
25    #[must_use]
26    pub fn new(operation: impl Into<String>) -> Self {
27        Self {
28            operation: operation.into(),
29            files: Vec::new(),
30            index: BTreeMap::new(),
31        }
32    }
33
34    /// Opens or reuses a file entry. Edits added after this land on it.
35    ///
36    /// Reusing keeps one entry per path: two entries for the same file would each carry the
37    /// same "before" hash and the second would be applied to bytes the first already changed.
38    #[must_use]
39    pub fn file(mut self, path: &str, sha256: &str) -> Self {
40        if !self.index.contains_key(path) {
41            self.index.insert(path.to_owned(), self.files.len());
42            self.files
43                .push((path.to_owned(), sha256.to_owned(), Vec::new()));
44        }
45        self
46    }
47
48    /// Adds one byte-exact edit to the file opened last.
49    #[must_use]
50    #[allow(clippy::too_many_arguments)]
51    pub fn edit(
52        mut self,
53        start_line: u32,
54        start_char: u32,
55        end_line: u32,
56        end_char: u32,
57        before: impl Into<String>,
58        after: impl Into<String>,
59        provenance: &str,
60    ) -> Self {
61        if let Some((_, _, edits)) = self.files.last_mut() {
62            edits.push(json!({
63                "startLine": start_line,
64                "startChar": start_char,
65                "endLine": end_line,
66                "endChar": end_char,
67                "before": before.into(),
68                "after": after.into(),
69                "provenance": provenance,
70            }));
71        }
72        self
73    }
74
75    /// Whether anything would actually be written.
76    #[must_use]
77    pub fn is_empty(&self) -> bool {
78        self.files.iter().all(|(_, _, edits)| edits.is_empty())
79    }
80
81    /// Finishes the envelope.
82    #[must_use]
83    pub fn build(self) -> Value {
84        json!({
85            "schemaVersion": "weavatrix.edit-plan.v1",
86            "operation": self.operation,
87            "files": self.files.into_iter()
88                .filter(|(_, _, edits)| !edits.is_empty())
89                .map(|(path, sha256, edits)| json!({
90                    "path": path,
91                    "sha256": sha256,
92                    "edits": edits,
93                }))
94                .collect::<Vec<_>>(),
95        })
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::{PlanBuilder, sha256_of};
102    use blazingly_json::Value;
103
104    #[test]
105    fn the_envelope_carries_the_frozen_schema_version() {
106        let plan = PlanBuilder::new("edit_symbol")
107            .file("a.rs", &sha256_of("x"))
108            .edit(1, 0, 1, 1, "x", "y", "EXTRACTED")
109            .build();
110        assert_eq!(
111            plan.get("schemaVersion").and_then(Value::as_str),
112            Some("weavatrix.edit-plan.v1")
113        );
114    }
115
116    #[test]
117    fn one_path_gets_one_file_entry_however_many_edits() {
118        let plan = PlanBuilder::new("bulk_replace")
119            .file("a.rs", &sha256_of("x"))
120            .edit(1, 0, 1, 1, "x", "y", "LEXICAL_EXACT")
121            .file("a.rs", &sha256_of("x"))
122            .edit(2, 0, 2, 1, "x", "y", "LEXICAL_EXACT")
123            .build();
124        let files = plan.get("files").and_then(Value::as_array).expect("files");
125        assert_eq!(
126            files.len(),
127            1,
128            "a second entry would apply to already-edited bytes"
129        );
130        assert_eq!(
131            files[0]
132                .get("edits")
133                .and_then(Value::as_array)
134                .map(Vec::len),
135            Some(2)
136        );
137    }
138
139    #[test]
140    fn a_file_with_no_edits_is_dropped_rather_than_shipped_empty() {
141        let plan = PlanBuilder::new("bulk_replace")
142            .file("untouched.rs", &sha256_of("x"))
143            .build();
144        assert_eq!(
145            plan.get("files").and_then(Value::as_array).map(Vec::len),
146            Some(0)
147        );
148    }
149
150    #[test]
151    fn emptiness_is_visible_before_building() {
152        let empty = PlanBuilder::new("bulk_replace").file("a.rs", "hash");
153        assert!(empty.is_empty());
154        let filled = PlanBuilder::new("bulk_replace").file("a.rs", "hash").edit(
155            1,
156            0,
157            1,
158            1,
159            "x",
160            "y",
161            "LEXICAL_EXACT",
162        );
163        assert!(!filled.is_empty());
164    }
165
166    #[test]
167    fn the_hash_is_the_one_the_applier_recomputes() {
168        // Same input, same digest as weavatrix-worktree computes when it re-reads the file.
169        let text = "pub fn one() {}\n";
170        assert_eq!(
171            sha256_of(text),
172            weavatrix_worktree::Sha256Hash::compute(text.as_bytes()).to_string()
173        );
174    }
175}