weavatrix_rust_refactor/
envelope.rs1use blazingly_json::Value;
9use weavatrix_refactor_plan::{EditPlan, FileEdit, Provenance, TextEdit};
10
11pub struct EnvelopeError {
13 pub code: &'static str,
14 pub reason: String,
15}
16
17impl EnvelopeError {
18 fn invalid(reason: impl Into<String>) -> Self {
19 Self {
20 code: "INVALID_PLAN",
21 reason: reason.into(),
22 }
23 }
24}
25
26fn provenance(value: &str) -> Option<Provenance> {
32 let declared = Provenance::new(value);
33 declared.is_applicable().then_some(declared)
34}
35
36fn required_str(value: &Value, key: &str, context: &str) -> Result<String, EnvelopeError> {
37 value
38 .get(key)
39 .and_then(Value::as_str)
40 .map(ToOwned::to_owned)
41 .ok_or_else(|| EnvelopeError::invalid(format!("{context}: {key} must be a string")))
42}
43
44fn required_u32(value: &Value, key: &str, context: &str) -> Result<u32, EnvelopeError> {
45 value
46 .get(key)
47 .and_then(Value::as_u64)
48 .and_then(|number| u32::try_from(number).ok())
49 .ok_or_else(|| {
50 EnvelopeError::invalid(format!("{context}: {key} must be a non-negative integer"))
51 })
52}
53
54fn text_edit(value: &Value, context: &str) -> Result<TextEdit, EnvelopeError> {
55 let declared = required_str(value, "provenance", context)?;
56 let Some(provenance) = provenance(&declared) else {
57 return Err(EnvelopeError {
58 code: "INVALID_PLAN",
59 reason: format!(
60 "{context}: provenance {declared} is not applyable; only EXACT_LSP, RESOLVED, \
61 EXTRACTED and LEXICAL_EXACT edits are ever written"
62 ),
63 });
64 };
65 Ok(TextEdit {
66 start_line: required_u32(value, "startLine", context)?,
67 start_char: required_u32(value, "startChar", context)?,
68 end_line: required_u32(value, "endLine", context)?,
69 end_char: required_u32(value, "endChar", context)?,
70 before: required_str(value, "before", context)?,
71 after: required_str(value, "after", context)?,
72 provenance,
73 extensions: std::collections::BTreeMap::new(),
74 })
75}
76
77pub fn read_envelope(plan: &Value) -> Result<EditPlan, EnvelopeError> {
84 let schema = required_str(plan, "schemaVersion", "plan")?;
85 if schema != "weavatrix.edit-plan.v1" {
86 return Err(EnvelopeError::invalid(format!(
87 "plan: schemaVersion must be weavatrix.edit-plan.v1, found {schema}"
88 )));
89 }
90 let operation = required_str(plan, "operation", "plan")?;
91 let files = plan
92 .get("files")
93 .and_then(Value::as_array)
94 .ok_or_else(|| EnvelopeError::invalid("plan: files must be an array"))?;
95 if files.is_empty() {
96 return Err(EnvelopeError::invalid("plan: files must not be empty"));
97 }
98 let mut parsed = Vec::with_capacity(files.len());
99 for file in files {
100 let path = required_str(file, "path", "plan file")?;
101 let sha256 = required_str(file, "sha256", &format!("plan file {path}"))?;
102 let edits = file.get("edits").and_then(Value::as_array).ok_or_else(|| {
103 EnvelopeError::invalid(format!("plan file {path}: edits must be an array"))
104 })?;
105 if edits.is_empty() {
106 return Err(EnvelopeError::invalid(format!(
107 "plan file {path}: edits must not be empty"
108 )));
109 }
110 let mut typed = Vec::with_capacity(edits.len());
111 for edit in edits {
112 typed.push(text_edit(edit, &format!("plan file {path}"))?);
113 }
114 parsed.push(FileEdit::new(path, sha256, typed));
115 }
116 Ok(EditPlan::new(operation, parsed))
117}
118
119#[cfg(test)]
120mod tests {
121 use super::read_envelope;
122 use blazingly_json::json;
123
124 fn envelope(provenance: &str) -> blazingly_json::Value {
125 json!({
126 "schemaVersion": "weavatrix.edit-plan.v1",
127 "operation": "rename_symbol",
128 "files": [{
129 "path": "src/a.rs",
130 "sha256": "0".repeat(64),
131 "edits": [{
132 "startLine": 1, "startChar": 4, "endLine": 1, "endChar": 7,
133 "before": "one", "after": "two", "provenance": provenance,
134 }],
135 }],
136 })
137 }
138
139 #[test]
140 fn a_proven_envelope_reads() {
141 for proven in ["EXACT_LSP", "RESOLVED", "EXTRACTED", "LEXICAL_EXACT"] {
142 let plan = read_envelope(&envelope(proven)).map_err(|error| error.reason);
143 assert!(plan.is_ok(), "{proven} must be applyable: {plan:?}");
144 }
145 }
146
147 #[test]
148 fn an_inferred_edit_is_refused() {
149 let error = read_envelope(&envelope("INFERRED")).unwrap_err();
150 assert_eq!(error.code, "INVALID_PLAN");
151 assert!(error.reason.contains("not applyable"));
152 }
153
154 #[test]
155 fn a_missing_hash_is_refused_rather_than_defaulted() {
156 let mut plan = envelope("EXACT_LSP");
157 if let Some(file) = plan
158 .get_mut("files")
159 .and_then(|files| files.as_array_mut())
160 .and_then(|files| files.first_mut())
161 .and_then(|file| file.as_object_mut())
162 {
163 file.remove("sha256");
164 }
165 let error = read_envelope(&plan).unwrap_err();
166 assert!(error.reason.contains("sha256"));
167 }
168
169 #[test]
170 fn the_wrong_schema_is_refused() {
171 let mut plan = envelope("EXACT_LSP");
172 if let Some(object) = plan.as_object_mut() {
173 object.insert("schemaVersion".to_owned(), "weavatrix.edit-plan.v2".into());
174 }
175 assert!(read_envelope(&plan).is_err());
176 }
177
178 #[test]
179 fn an_empty_plan_is_refused() {
180 let mut plan = envelope("EXACT_LSP");
181 if let Some(object) = plan.as_object_mut() {
182 object.insert("files".to_owned(), blazingly_json::Value::Array(vec![]));
183 }
184 assert!(read_envelope(&plan).is_err());
185 }
186}