weavatrix_rust_refactor/
plan.rs1use blazingly_json::{Value, json};
8use std::collections::BTreeMap;
9
10#[must_use]
12pub fn sha256_of(text: &str) -> String {
13 weavatrix_worktree::Sha256Hash::compute(text.as_bytes()).to_string()
14}
15
16pub struct PlanBuilder {
18 operation: String,
19 files: Vec<(String, String, Vec<Value>)>,
20 index: BTreeMap<String, usize>,
21}
22
23impl PlanBuilder {
24 #[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 #[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 #[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 #[must_use]
77 pub fn is_empty(&self) -> bool {
78 self.files.iter().all(|(_, _, edits)| edits.is_empty())
79 }
80
81 #[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 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}