Skip to main content

weavatrix_refactor_plan/
conversion.rs

1use crate::{EditPlan, PlanAnnotations, PlanError, PlanErrorCode, RefactorOperation, RefactorPlan};
2use blazingly_json::Value;
3
4const LEGACY_TOP_LEVEL_FIELDS: [&str; 12] = [
5    "schemaVersion",
6    "operation",
7    "files",
8    "completeness",
9    "createdAt",
10    "graphRevision",
11    "completenessProof",
12    "uncertainReferences",
13    "notModified",
14    "warnings",
15    "followUp",
16    "syntaxCheck",
17];
18
19pub(crate) fn from_text_edit_plan(plan: EditPlan) -> Result<RefactorPlan, PlanError> {
20    let evidence = extract_annotations(&plan)?;
21    Ok(RefactorPlan {
22        schema_version: crate::REFACTOR_PLAN_SCHEMA.to_owned(),
23        operation: plan.operation,
24        operations: plan
25            .files
26            .into_iter()
27            .map(RefactorOperation::Modify)
28            .collect(),
29        completeness: plan.completeness,
30        evidence,
31    })
32}
33
34pub(crate) fn into_text_edit_plan(plan: RefactorPlan) -> Result<EditPlan, PlanError> {
35    let mut files = Vec::with_capacity(plan.operations.len());
36    for (index, operation) in plan.operations.into_iter().enumerate() {
37        let RefactorOperation::Modify(file) = operation else {
38            return Err(PlanError::new(
39                PlanErrorCode::NotTextOnly,
40                "legacy edit-plan conversion requires only modify operations",
41            )
42            .at_operation(index));
43        };
44        files.push(file);
45    }
46    let mut edit = EditPlan::new(plan.operation, files);
47    edit.completeness = plan.completeness;
48    attach_annotations(edit, &plan.evidence)
49}
50
51/// Reads typed evidence from a legacy edit plan without changing it.
52///
53/// Reads `plan.extensions`, which is where the whole v1 annotation set travels.
54/// A plan decoded through [`crate::weavatrix_edit::DeclaredEditPlan`] carries an
55/// empty map, and this returns `Ok(PlanAnnotations::default())` — indistinguishable
56/// from a producer that genuinely sent no annotations.
57pub fn extract_annotations(plan: &EditPlan) -> Result<PlanAnnotations, PlanError> {
58    let object = plan.extensions.clone().into();
59    blazingly_json::from_value(Value::Object(object)).map_err(|error| {
60        PlanError::new(
61            PlanErrorCode::EvidenceMalformed,
62            format!("plan evidence is malformed: {error}"),
63        )
64    })
65}
66
67/// Attaches typed evidence to a legacy edit plan without overwriting fields.
68///
69/// The [`PlanErrorCode::ExtensionConflict`] guard below compares against the
70/// plan's existing `extensions`. On a plan decoded through
71/// [`crate::weavatrix_edit::DeclaredEditPlan`] that map is empty, so the guard
72/// cannot fire and a genuinely colliding wire document is accepted with its
73/// original members already gone.
74pub fn attach_annotations(
75    mut plan: EditPlan,
76    annotations: &PlanAnnotations,
77) -> Result<EditPlan, PlanError> {
78    validate_evidence_extension_keys(annotations)?;
79    let Value::Object(object) = encode_evidence(annotations)? else {
80        return Err(json_shape_error());
81    };
82    for (key, value) in object {
83        if plan.extensions.insert(key.clone(), value).is_some() {
84            return Err(PlanError::new(
85                PlanErrorCode::ExtensionConflict,
86                format!("plan extension {key:?} already exists"),
87            )
88            .at_field(key));
89        }
90    }
91    Ok(plan)
92}
93
94/// Removes all legacy top-level extensions and decodes them as evidence.
95///
96/// Reads `plan.extensions`; see [`extract_annotations`] for what a plan decoded
97/// through [`crate::weavatrix_edit::DeclaredEditPlan`] yields.
98pub fn detach_annotations(mut plan: EditPlan) -> Result<(EditPlan, PlanAnnotations), PlanError> {
99    let annotations = extract_annotations(&plan)?;
100    plan.extensions.clear();
101    Ok((plan, annotations))
102}
103
104/// Replaces legacy edit-plan evidence while preserving frozen edit fields.
105///
106/// Reads `plan.extensions` through [`detach_annotations`], so the same
107/// declared-only decode caveat applies.
108pub fn replace_annotations(
109    plan: EditPlan,
110    annotations: &PlanAnnotations,
111) -> Result<EditPlan, PlanError> {
112    let (base, _) = detach_annotations(plan)?;
113    attach_annotations(base, annotations)
114}
115
116fn encode_evidence(annotations: &PlanAnnotations) -> Result<Value, PlanError> {
117    blazingly_json::to_value(annotations).map_err(|error| {
118        PlanError::new(
119            PlanErrorCode::JsonEncoding,
120            format!("could not encode plan evidence: {error}"),
121        )
122    })
123}
124
125fn validate_evidence_extension_keys(annotations: &PlanAnnotations) -> Result<(), PlanError> {
126    check_keys(
127        &annotations.extensions,
128        &LEGACY_TOP_LEVEL_FIELDS,
129        "evidence",
130    )?;
131    if let Some(proof) = &annotations.completeness_proof {
132        check_keys(
133            &proof.extensions,
134            &["scope", "planner"],
135            "completenessProof",
136        )?;
137        check_keys(
138            &proof.scope.extensions,
139            &["kind", "value", "roots", "languages"],
140            "completenessProof.scope",
141        )?;
142        check_keys(
143            &proof.planner.extensions,
144            &["name", "version", "backend", "backendVersion"],
145            "completenessProof.planner",
146        )?;
147    }
148    for (index, reference) in annotations
149        .uncertain_references
150        .iter()
151        .flatten()
152        .enumerate()
153    {
154        check_keys(
155            &reference.extensions,
156            &[
157                "path", "file", "line", "subject", "kind", "reason", "excerpt",
158            ],
159            &format!("uncertainReferences[{index}]"),
160        )?;
161        if let Some(subject) = &reference.subject {
162            check_keys(
163                &subject.extensions,
164                &["kind", "value"],
165                &format!("uncertainReferences[{index}].subject"),
166            )?;
167        }
168    }
169    for (index, entry) in annotations.not_modified.iter().flatten().enumerate() {
170        check_keys(
171            &entry.extensions,
172            &["path", "file", "subject", "operationIndex", "reason"],
173            &format!("notModified[{index}]"),
174        )?;
175        if let Some(subject) = &entry.subject {
176            check_keys(
177                &subject.extensions,
178                &["kind", "value"],
179                &format!("notModified[{index}].subject"),
180            )?;
181        }
182    }
183    Ok(())
184}
185
186fn check_keys(
187    extensions: &std::collections::BTreeMap<String, Value>,
188    reserved: &[&str],
189    field: &str,
190) -> Result<(), PlanError> {
191    if let Some(key) = extensions
192        .keys()
193        .find(|key| reserved.contains(&key.as_str()))
194    {
195        return Err(PlanError::new(
196            PlanErrorCode::ExtensionConflict,
197            format!("extension {key:?} collides with a reserved field"),
198        )
199        .at_field(format!("{field}.{key}")));
200    }
201    Ok(())
202}
203
204fn json_shape_error() -> PlanError {
205    PlanError::new(
206        PlanErrorCode::JsonEncoding,
207        "plan evidence did not encode as an object",
208    )
209}