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
51pub 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
67pub 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
94pub 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
104pub 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}