use blazingly_json::Value;
use weavatrix_refactor_plan::{EditPlan, FileEdit, Provenance, TextEdit};
pub struct EnvelopeError {
pub code: &'static str,
pub reason: String,
}
impl EnvelopeError {
fn invalid(reason: impl Into<String>) -> Self {
Self {
code: "INVALID_PLAN",
reason: reason.into(),
}
}
}
fn provenance(value: &str) -> Option<Provenance> {
let declared = Provenance::new(value);
declared.is_applicable().then_some(declared)
}
fn required_str(value: &Value, key: &str, context: &str) -> Result<String, EnvelopeError> {
value
.get(key)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| EnvelopeError::invalid(format!("{context}: {key} must be a string")))
}
fn required_u32(value: &Value, key: &str, context: &str) -> Result<u32, EnvelopeError> {
value
.get(key)
.and_then(Value::as_u64)
.and_then(|number| u32::try_from(number).ok())
.ok_or_else(|| {
EnvelopeError::invalid(format!("{context}: {key} must be a non-negative integer"))
})
}
fn text_edit(value: &Value, context: &str) -> Result<TextEdit, EnvelopeError> {
let declared = required_str(value, "provenance", context)?;
let Some(provenance) = provenance(&declared) else {
return Err(EnvelopeError {
code: "INVALID_PLAN",
reason: format!(
"{context}: provenance {declared} is not applyable; only EXACT_LSP, RESOLVED, \
EXTRACTED and LEXICAL_EXACT edits are ever written"
),
});
};
Ok(TextEdit {
start_line: required_u32(value, "startLine", context)?,
start_char: required_u32(value, "startChar", context)?,
end_line: required_u32(value, "endLine", context)?,
end_char: required_u32(value, "endChar", context)?,
before: required_str(value, "before", context)?,
after: required_str(value, "after", context)?,
provenance,
extensions: std::collections::BTreeMap::new(),
})
}
pub fn read_envelope(plan: &Value) -> Result<EditPlan, EnvelopeError> {
let schema = required_str(plan, "schemaVersion", "plan")?;
if schema != "weavatrix.edit-plan.v1" {
return Err(EnvelopeError::invalid(format!(
"plan: schemaVersion must be weavatrix.edit-plan.v1, found {schema}"
)));
}
let operation = required_str(plan, "operation", "plan")?;
let files = plan
.get("files")
.and_then(Value::as_array)
.ok_or_else(|| EnvelopeError::invalid("plan: files must be an array"))?;
if files.is_empty() {
return Err(EnvelopeError::invalid("plan: files must not be empty"));
}
let mut parsed = Vec::with_capacity(files.len());
for file in files {
let path = required_str(file, "path", "plan file")?;
let sha256 = required_str(file, "sha256", &format!("plan file {path}"))?;
let edits = file.get("edits").and_then(Value::as_array).ok_or_else(|| {
EnvelopeError::invalid(format!("plan file {path}: edits must be an array"))
})?;
if edits.is_empty() {
return Err(EnvelopeError::invalid(format!(
"plan file {path}: edits must not be empty"
)));
}
let mut typed = Vec::with_capacity(edits.len());
for edit in edits {
typed.push(text_edit(edit, &format!("plan file {path}"))?);
}
parsed.push(FileEdit::new(path, sha256, typed));
}
Ok(EditPlan::new(operation, parsed))
}
#[cfg(test)]
mod tests {
use super::read_envelope;
use blazingly_json::json;
fn envelope(provenance: &str) -> blazingly_json::Value {
json!({
"schemaVersion": "weavatrix.edit-plan.v1",
"operation": "rename_symbol",
"files": [{
"path": "src/a.rs",
"sha256": "0".repeat(64),
"edits": [{
"startLine": 1, "startChar": 4, "endLine": 1, "endChar": 7,
"before": "one", "after": "two", "provenance": provenance,
}],
}],
})
}
#[test]
fn a_proven_envelope_reads() {
for proven in ["EXACT_LSP", "RESOLVED", "EXTRACTED", "LEXICAL_EXACT"] {
let plan = read_envelope(&envelope(proven)).map_err(|error| error.reason);
assert!(plan.is_ok(), "{proven} must be applyable: {plan:?}");
}
}
#[test]
fn an_inferred_edit_is_refused() {
let error = read_envelope(&envelope("INFERRED")).unwrap_err();
assert_eq!(error.code, "INVALID_PLAN");
assert!(error.reason.contains("not applyable"));
}
#[test]
fn a_missing_hash_is_refused_rather_than_defaulted() {
let mut plan = envelope("EXACT_LSP");
if let Some(file) = plan
.get_mut("files")
.and_then(|files| files.as_array_mut())
.and_then(|files| files.first_mut())
.and_then(|file| file.as_object_mut())
{
file.remove("sha256");
}
let error = read_envelope(&plan).unwrap_err();
assert!(error.reason.contains("sha256"));
}
#[test]
fn the_wrong_schema_is_refused() {
let mut plan = envelope("EXACT_LSP");
if let Some(object) = plan.as_object_mut() {
object.insert("schemaVersion".to_owned(), "weavatrix.edit-plan.v2".into());
}
assert!(read_envelope(&plan).is_err());
}
#[test]
fn an_empty_plan_is_refused() {
let mut plan = envelope("EXACT_LSP");
if let Some(object) = plan.as_object_mut() {
object.insert("files".to_owned(), blazingly_json::Value::Array(vec![]));
}
assert!(read_envelope(&plan).is_err());
}
}