Skip to main content

weavatrix_rust_refactor/
envelope.rs

1//! Reading a `weavatrix.edit-plan.v1` envelope into the typed plan the worktree applies.
2//!
3//! The mapping is written out rather than derived, because every field here is load-bearing and
4//! a silent default would be a hole in the proof: a missing `sha256` that became an empty string
5//! would turn a stale-file check into a no-op, and an unknown provenance that fell back to a
6//! permissive value would let an inferred edit through the gate that exists to stop it.
7
8use blazingly_json::Value;
9use weavatrix_refactor_plan::{EditPlan, FileEdit, Provenance, TextEdit};
10
11/// Why an envelope could not be read, in the vocabulary the contract already uses.
12pub 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
26/// Provenance classes this package will apply.
27///
28/// The applicable set is `weavatrix-edit`'s to define, so this asks rather than restates it —
29/// a second copy of that list here would be one more place for `INFERRED` to leak through the
30/// gate that exists to stop it.
31fn 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
77/// Reads an envelope, refusing anything the applier could not prove.
78///
79/// # Errors
80///
81/// Returns `INVALID_PLAN` with the field at fault. Nothing is read from disk here, so a refusal
82/// costs nothing and never half-touches the working tree.
83pub 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}