1use crate::{GeneratorError, merge_schema_extensions};
12use serde_json::{Map, Value};
13use serde_json_path::JsonPath;
14use std::path::{Component, Path, PathBuf};
15
16pub fn preprocess_spec(
18 spec: Value,
19 schema_extensions: &[PathBuf],
20 overlays: &[PathBuf],
21) -> Result<Value, GeneratorError> {
22 let mut document = merge_schema_extensions(spec, schema_extensions)?;
23 for path in overlays {
24 let content = std::fs::read_to_string(path).map_err(|error| {
25 overlay_error(&path.display().to_string(), None, &error.to_string())
26 })?;
27 let overlay =
28 crate::spec_source::parse_spec(&content, &path.to_string_lossy()).map_err(|error| {
29 overlay_error(&path.display().to_string(), None, &error.to_string())
30 })?;
31 document = apply_overlay(document, &overlay, &path.display().to_string())?;
32 }
33 Ok(document)
34}
35
36pub fn apply_overlay(
43 mut document: Value,
44 overlay: &Value,
45 source: &str,
46) -> Result<Value, GeneratorError> {
47 let run = || -> Result<(), String> {
48 let object = require_object(overlay, "overlay document")?;
49 validate_fields(object, &["overlay", "info", "extends", "actions"])?;
50 let version = require_string(object, "overlay")?;
51 let version_parts: Vec<_> = version.split('.').collect();
52 if !matches!(version_parts.as_slice(), ["1", "1", patch]
53 if !patch.is_empty() && patch.bytes().all(|byte| byte.is_ascii_digit()))
54 {
55 return Err(format!(
56 "unsupported Overlay version {version:?}; expected 1.1.<numeric patch>"
57 ));
58 }
59 let info = require_object(object.get("info").ok_or("missing info")?, "info")?;
60 validate_fields(info, &["title", "version", "description"])?;
61 require_string(info, "title")?;
62 require_string(info, "version")?;
63 optional_string(info, "description")?;
64 optional_string(object, "extends")?;
65 let actions = object
66 .get("actions")
67 .and_then(Value::as_array)
68 .ok_or("actions must be a nonempty array")?;
69 if actions.is_empty() {
70 return Err("actions must be a nonempty array".into());
71 }
72 Ok(())
73 };
74 run().map_err(|error| overlay_error(source, None, &error.to_string()))?;
75 if let Some(actions) = overlay.get("actions").and_then(Value::as_array) {
77 for (index, action) in actions.iter().enumerate() {
78 apply_action(&mut document, action)
79 .map_err(|error| overlay_error(source, Some(index + 1), &error))?;
80 }
81 }
82 Ok(document)
83}
84
85fn overlay_error(source: &str, action: Option<usize>, message: &str) -> GeneratorError {
86 let location = action.map_or_else(String::new, |index| format!(", action {index}"));
87 GeneratorError::ValidationError(format!("Overlay '{source}'{location}: {message}"))
88}
89
90fn require_object<'a>(value: &'a Value, label: &str) -> Result<&'a Map<String, Value>, String> {
91 value
92 .as_object()
93 .ok_or_else(|| format!("{label} must be an object"))
94}
95
96fn require_string<'a>(object: &'a Map<String, Value>, field: &str) -> Result<&'a str, String> {
97 object
98 .get(field)
99 .and_then(Value::as_str)
100 .ok_or_else(|| format!("{field} must be a string"))
101}
102
103fn optional_string(object: &Map<String, Value>, field: &str) -> Result<(), String> {
104 if object.contains_key(field) {
105 require_string(object, field)?;
106 }
107 Ok(())
108}
109
110fn validate_fields(object: &Map<String, Value>, fields: &[&str]) -> Result<(), String> {
111 for field in object.keys() {
112 if !fields.contains(&field.as_str()) && !field.starts_with("x-") {
113 return Err(format!("unknown Overlay field {field:?}"));
114 }
115 }
116 Ok(())
117}
118
119fn kind(value: &Value) -> u8 {
120 match value {
121 Value::Object(_) => 0,
122 Value::Array(_) => 1,
123 _ => 2,
124 }
125}
126
127#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
128enum LocationElement {
129 Name(String),
130 Index(usize),
131}
132
133#[derive(Clone)]
134struct Location {
135 pointer: String,
136 elements: Vec<LocationElement>,
137}
138
139fn apply_action(document: &mut Value, action: &Value) -> Result<(), String> {
140 let action = require_object(action, "action")?;
141 validate_fields(
142 action,
143 &["target", "description", "update", "copy", "remove"],
144 )?;
145 optional_string(action, "description")?;
146 optional_string(action, "copy")?;
147 let remove = match action.get("remove") {
148 None => false,
149 Some(Value::Bool(value)) => *value,
150 Some(_) => return Err("remove must be a boolean".into()),
151 };
152 let target = require_string(action, "target")?;
153 let query =
154 JsonPath::parse(target).map_err(|error| format!("invalid target {target:?}: {error}"))?;
155 let update = action.get("update");
156 let copy = action.get("copy").and_then(Value::as_str);
157 let copy_query = if remove {
160 None
161 } else {
162 if update.is_some() && copy.is_some() {
163 return Err(
164 "update and copy cannot both be active in one action; use separate ordered actions"
165 .into(),
166 );
167 }
168 copy.map(|expression| {
169 JsonPath::parse(expression)
170 .map_err(|error| format!("invalid copy {expression:?}: {error}"))
171 })
172 .transpose()?
173 };
174 let selected = query.query_located(document).dedup();
175 let mut locations: Vec<Location> = selected
176 .locations()
177 .map(|location| Location {
178 pointer: location.to_json_pointer(),
179 elements: location
180 .iter()
181 .map(|element| match element {
182 serde_json_path::PathElement::Name(name) => {
183 LocationElement::Name((*name).to_string())
184 }
185 serde_json_path::PathElement::Index(index) => LocationElement::Index(*index),
186 })
187 .collect(),
188 })
189 .collect();
190 if locations.is_empty() {
193 return Ok(());
194 }
195 if !remove
196 && (update.is_some() || copy.is_some())
197 && selected
198 .nodes()
199 .map(kind)
200 .collect::<std::collections::BTreeSet<_>>()
201 .len()
202 > 1
203 {
204 return Err(
205 "update/copy targets must be all objects, all arrays, or all primitives".into(),
206 );
207 }
208 locations.sort_by(|left, right| {
209 right
210 .elements
211 .len()
212 .cmp(&left.elements.len())
213 .then_with(|| right.elements.cmp(&left.elements))
214 });
215 if remove {
216 if locations
217 .iter()
218 .any(|location| location.elements.is_empty())
219 {
220 return Err(
221 "cannot remove the root document; it has no containing map or array".into(),
222 );
223 }
224 for location in locations {
225 remove_location(document, &location)?;
226 }
227 return Ok(());
228 }
229 let payload = if let Some(update) = update {
230 update.clone()
231 } else if let (Some(copy), Some(path)) = (copy, copy_query) {
232 path.query(document)
233 .exactly_one()
234 .map_err(|error| format!("copy {copy:?} must select exactly one node: {error}"))?
235 .clone()
236 } else {
237 return Ok(());
238 };
239 for location in locations {
240 let target = document
241 .pointer_mut(&location.pointer)
242 .ok_or_else(|| format!("target {} disappeared during action", location.pointer))?;
243 merge_target(target, &payload, &location.pointer)?;
244 }
245 Ok(())
246}
247
248fn remove_location(document: &mut Value, location: &Location) -> Result<(), String> {
249 let (parent_pointer, _) = location
250 .pointer
251 .rsplit_once('/')
252 .ok_or("missing parent location")?;
253 let parent = document
254 .pointer_mut(parent_pointer)
255 .ok_or("missing target parent")?;
256 match (parent, location.elements.last()) {
257 (Value::Object(object), Some(LocationElement::Name(name))) => {
258 object.remove(name);
259 }
260 (Value::Array(array), Some(LocationElement::Index(index))) if *index < array.len() => {
261 array.remove(*index);
262 }
263 _ => return Err(format!("cannot remove target {}", location.pointer)),
264 }
265 Ok(())
266}
267
268fn merge_target(target: &mut Value, update: &Value, pointer: &str) -> Result<(), String> {
269 match (target, update) {
270 (Value::Object(target), Value::Object(update)) => {
271 for (name, value) in update {
272 if let Some(existing) = target.get_mut(name) {
273 let child = format!("{pointer}/{}", name.replace('~', "~0").replace('/', "~1"));
274 if kind(existing) != kind(value) {
276 return Err(format!("incompatible value kinds at {child}"));
277 }
278 merge_target(existing, value, &child)?;
279 } else {
280 target.insert(name.clone(), value.clone());
281 }
282 }
283 }
284 (Value::Array(target), Value::Array(update)) => target.extend(update.iter().cloned()),
285 (Value::Array(target), value) => target.push(value.clone()),
286 (target, update) if kind(target) == 2 && kind(update) == 2 => *target = update.clone(),
287 _ => return Err(format!("incompatible value kinds at {pointer}")),
288 }
289 Ok(())
290}
291
292pub fn validate_artifact_path(path: &Path) -> Result<(), GeneratorError> {
294 if path.as_os_str().is_empty()
295 || path
296 .components()
297 .any(|part| !matches!(part, Component::Normal(_)))
298 {
299 return Err(GeneratorError::ValidationError(format!(
300 "artifact path '{}' must be a nonempty relative filename inside output_dir",
301 path.display()
302 )));
303 }
304 Ok(())
305}
306
307pub fn effective_spec_json(document: &Value) -> Result<String, GeneratorError> {
309 fn canonical(value: &Value) -> Value {
312 match value {
313 Value::Object(map) => Value::Object(
314 map.iter()
315 .map(|(key, value)| (key.clone(), canonical(value)))
316 .collect::<std::collections::BTreeMap<_, _>>()
317 .into_iter()
318 .collect(),
319 ),
320 Value::Array(array) => Value::Array(array.iter().map(canonical).collect()),
321 _ => value.clone(),
322 }
323 }
324 Ok(format!(
325 "{}\n",
326 serde_json::to_string_pretty(&canonical(document))?
327 ))
328}
329
330#[cfg(test)]
331mod tests {
332 use super::apply_overlay;
333 use serde_json::json;
334
335 #[test]
336 fn inactive_actions_allow_mixed_target_kinds() {
337 let document = json!([{}, [], null, 1, true, "text"]);
338 let overlay = json!({"overlay": "1.1.0", "info": {"title": "No-op", "version": "1"}, "actions": [
339 {"target": "$[*]"},
340 {"target": "$[*]", "remove": false}
341 ]});
342 match apply_overlay(document.clone(), &overlay, "inactive.overlay.json") {
343 Ok(effective) => assert_eq!(effective, document),
344 Err(error) => panic!("inactive action failed: {error}"),
345 }
346 }
347
348 #[test]
349 fn overlay_one_one_patch_versions_are_ignored() {
350 for version in ["1.1.0", "1.1.1", "1.1.98765432109876543210"] {
351 let overlay = json!({"overlay": version, "info": {"title": "Version", "version": "1"}, "actions": [{"target": "$"}]});
352 assert!(
353 apply_overlay(json!({}), &overlay, "version.overlay.json").is_ok(),
354 "{version}"
355 );
356 }
357 for version in [
358 "1.0.0", "1.2.0", "2.1.0", "1.1", "1.1.", "1.1.a", "1.1.-1", "1.1.0.1", " 1.1.0",
359 "1.1.0 ",
360 ] {
361 let overlay = json!({"overlay": version, "info": {"title": "Version", "version": "1"}, "actions": [{"target": "$"}]});
362 assert!(
363 apply_overlay(json!({}), &overlay, "version.overlay.json").is_err(),
364 "{version}"
365 );
366 }
367 }
368
369 #[test]
370 fn unmatched_targets_do_not_hide_invalid_active_copy_actions() {
371 for (action, expected) in [
372 (
373 json!({"target": "$.missing", "copy": "not-jsonpath"}),
374 "invalid copy",
375 ),
376 (
377 json!({"target": "$.missing", "update": null, "copy": "$.source"}),
378 "update and copy",
379 ),
380 ] {
381 let overlay = json!({"overlay": "1.1.0", "info": {"title": "Validation", "version": "1"}, "actions": [action]});
382 let error = match apply_overlay(json!({}), &overlay, "invalid.overlay.json") {
383 Err(error) => error.to_string(),
384 Ok(_) => panic!("invalid action succeeded"),
385 };
386 assert!(error.contains(expected), "{error}");
387 assert!(error.contains("invalid.overlay.json', action 1"), "{error}");
388 }
389 }
390
391 #[test]
392 fn remove_precedence_and_valid_unmatched_copy_cardinality_are_preserved() {
393 let overlay = json!({"overlay": "1.1.0", "info": {"title": "No-op", "version": "1"}, "actions": [
394 {"target": "$.missing", "copy": "$.missing_source"},
395 {"target": "$.missing", "copy": "$[*]"},
396 {"target": "$.remove", "remove": true, "update": {}, "copy": "not-jsonpath"},
397 {"target": "$.missing", "remove": true, "update": {}, "copy": "not-jsonpath"}
398 ]});
399 match apply_overlay(
400 json!({"remove": 1, "keep": 2}),
401 &overlay,
402 "precedence.overlay.json",
403 ) {
404 Ok(document) => assert_eq!(document, json!({"keep": 2})),
405 Err(error) => panic!("valid no-op or remove action failed: {error}"),
406 }
407 }
408}