1use std::str::FromStr;
9
10use anyhow::Context as _;
11use yaml_edit::{Mapping, YamlFile, YamlNode};
12use yaml_serde::Value;
13
14pub(crate) fn apply_app_config_to_yaml(text: &str, target: &Value) -> anyhow::Result<String> {
28 match try_format_preserving_edit(text, target) {
29 Ok(out) if yaml_serde::from_str::<Value>(&out).is_ok() => Ok(out),
30 Ok(_) => {
31 tracing::warn!(
32 "format-preserving app YAML edit produced invalid YAML; \
33 rewriting the file without preserving formatting"
34 );
35 Ok(yaml_serde::to_string(target)?)
36 }
37 Err(err) => {
38 tracing::warn!(
39 ?err,
40 "cannot format-preserve app YAML edit; \
41 rewriting the file without preserving formatting"
42 );
43 Ok(yaml_serde::to_string(target)?)
44 }
45 }
46}
47
48fn try_format_preserving_edit(text: &str, target: &Value) -> anyhow::Result<String> {
49 let yaml_file = YamlFile::from_str(text)
50 .map_err(|e| anyhow::anyhow!("could not parse YAML for format-preserving edit: {e}"))?;
51 let doc = yaml_file
52 .document()
53 .context("could not find a document in YAML for format-preserving edit")?;
54 let original: Value = yaml_serde::from_str(text)
58 .map_err(|e| anyhow::anyhow!("could not parse YAML semantically: {e}"))?;
59
60 match (doc.as_mapping(), target, &original) {
61 (Some(mapping), Value::Mapping(target_mapping), Value::Mapping(original_mapping)) => {
62 merge_into_mapping(&mapping, target_mapping, Some(original_mapping), true)?;
63 Ok(yaml_file.to_string())
64 }
65 _ => Ok(yaml_serde::to_string(target)?),
69 }
70}
71
72fn merge_into_mapping(
82 mapping: &Mapping,
83 target: &yaml_serde::Mapping,
84 original: Option<&yaml_serde::Mapping>,
85 remove_missing: bool,
86) -> anyhow::Result<()> {
87 for (key_value, value) in target {
88 let Some(key) = scalar_key(key_value) else {
89 continue;
90 };
91
92 let original_value = original.and_then(|m| m.get(key_value));
93
94 if mapping.get(&key).is_some() {
95 if original_value == Some(value) {
96 continue;
98 }
99
100 if let (Some(child), Value::Mapping(target_child)) = (mapping.get_mapping(&key), value)
102 {
103 let original_child = match original_value {
104 Some(Value::Mapping(m)) => Some(m),
105 _ => None,
107 };
108 merge_into_mapping(&child, target_child, original_child, false)?;
109 } else {
110 set_value(mapping, &key, value)
111 .with_context(|| format!("could not update app YAML key `{key}`"))?;
112 }
113 } else if !value.is_null() {
114 set_value(mapping, &key, value)
116 .with_context(|| format!("could not add app YAML key `{key}`"))?;
117 }
118 }
119
120 if remove_missing {
121 for key in stale_top_level_keys(mapping, target) {
122 mapping.remove(key.as_str());
123 }
124 }
125
126 Ok(())
127}
128
129fn set_value(mapping: &Mapping, key: &str, value: &Value) -> anyhow::Result<()> {
133 match value {
134 Value::Null => mapping.set(key, yaml_edit::ScalarValue::null()),
135 Value::Bool(b) => mapping.set(key, *b),
136 Value::String(s) => mapping.set(key, s.as_str()),
137 Value::Number(n) => {
138 if let Some(i) = n.as_i64() {
139 mapping.set(key, i);
140 } else if let Some(u) = n.as_u64() {
141 mapping.set(key, u);
142 } else if let Some(f) = n.as_f64() {
143 mapping.set(key, f);
144 } else {
145 anyhow::bail!("cannot represent number for app YAML key `{key}`");
147 }
148 }
149 Value::Sequence(_) | Value::Mapping(_) | Value::Tagged(_) => {
150 anyhow::bail!("cannot format-preserve an edit to structured value `{key}`");
151 }
152 }
153
154 Ok(())
155}
156
157fn stale_top_level_keys(mapping: &Mapping, target: &yaml_serde::Mapping) -> Vec<String> {
158 mapping
159 .entries()
160 .filter_map(|entry| {
161 let key = entry.key_node()?;
162 let is_target_key = target
163 .keys()
164 .filter_map(scalar_key)
165 .any(|target_key| key.yaml_eq(&target_key));
166 if is_target_key {
167 None
168 } else {
169 scalar_key_node(&key)
170 }
171 })
172 .collect()
173}
174
175fn scalar_key(key: &Value) -> Option<String> {
178 match key {
179 Value::String(s) => Some(s.clone()),
180 Value::Bool(b) => Some(b.to_string()),
181 Value::Number(n) => Some(n.to_string()),
182 _ => None,
183 }
184}
185
186fn scalar_key_node(key: &YamlNode) -> Option<String> {
187 key.as_scalar().map(|s| s.as_string())
188}
189
190pub(crate) fn apply_app_config_to_yaml_file(
194 path: &std::path::Path,
195 target: &Value,
196) -> anyhow::Result<String> {
197 match std::fs::read_to_string(path) {
198 Ok(text) => apply_app_config_to_yaml(&text, target)
199 .with_context(|| format!("could not edit YAML file '{}'", path.display())),
200 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(yaml_serde::to_string(target)?),
201 Err(e) => Err(e).with_context(|| format!("could not read YAML file '{}'", path.display())),
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn parse(s: &str) -> Value {
210 yaml_serde::from_str(s).unwrap()
211 }
212
213 #[test]
214 fn preserves_comments_and_order_on_scalar_change() {
215 let original = r#"# my app
216kind: wasmer.io/App.v0
217name: my-app # the app name
218owner: alice
219package: .
220"#;
221 let target = parse(
223 r#"kind: wasmer.io/App.v0
224name: my-app
225owner: bob
226package: .
227"#,
228 );
229
230 let out = apply_app_config_to_yaml(original, &target).unwrap();
231 assert_eq!(
232 out,
233 r#"# my app
234kind: wasmer.io/App.v0
235name: my-app # the app name
236owner: bob
237package: .
238"#
239 );
240 }
241
242 #[test]
243 fn adds_new_key_without_touching_rest() {
244 let original = r#"kind: wasmer.io/App.v0
245name: my-app
246package: .
247"#;
248 let target = parse(
249 r#"kind: wasmer.io/App.v0
250name: my-app
251package: .
252app_id: da_abc123
253"#,
254 );
255
256 let out = apply_app_config_to_yaml(original, &target).unwrap();
257 assert!(out.contains("app_id: da_abc123"));
258 assert!(out.contains("kind: wasmer.io/App.v0\nname: my-app\npackage: ."));
259 }
260
261 #[test]
262 fn removes_top_level_key_absent_from_target() {
263 let original = r#"kind: wasmer.io/App.v0
264name: my-app
265app_id: da_old
266owner: alice
267package: .
268"#;
269 let target = parse(
271 r#"kind: wasmer.io/App.v0
272name: my-app
273owner: bob
274package: .
275"#,
276 );
277
278 let out = apply_app_config_to_yaml(original, &target).unwrap();
279 assert!(!out.contains("app_id"), "app_id should be removed: {out}");
280 assert!(out.contains("owner: bob"));
281 assert!(out.contains("name: my-app"));
282 }
283
284 #[test]
285 fn preserves_nested_mapping_and_comments_when_unchanged() {
286 let original = r#"kind: wasmer.io/App.v0
287name: my-app
288owner: alice
289package: .
290# capabilities for the app
291capabilities:
292 # memory limit
293 memory:
294 limit: 512MB
295env:
296 FOO: bar
297"#;
298 let target = parse(
300 r#"kind: wasmer.io/App.v0
301name: my-app
302owner: bob
303package: .
304capabilities:
305 memory:
306 limit: 512MB
307env:
308 FOO: bar
309"#,
310 );
311
312 let out = apply_app_config_to_yaml(original, &target).unwrap();
313 assert!(out.contains("# capabilities for the app"));
314 assert!(out.contains("# memory limit"));
315 assert!(out.contains("owner: bob"));
316 assert!(!out.contains("owner: alice"));
317 }
318
319 #[test]
325 fn deploy_flow_preserves_formatting_when_assigning_app_id() {
326 use wasmer_config::app::AppConfigV1;
327
328 let original = r#"# Wasmer app configuration
329kind: wasmer.io/App.v0
330name: my-cool-app # human readable name
331owner: alice
332package: .
333
334# how much memory the app gets
335capabilities:
336 memory:
337 limit: 512MB
338
339env:
340 LOG_LEVEL: debug
341"#;
342
343 let mut config = AppConfigV1::parse_yaml(original).unwrap();
345 assert!(config.app_id.is_none());
346
347 config.app_id = Some("da_abc123".to_string());
349
350 let target = config.clone().to_yaml_value().unwrap();
351 let out = apply_app_config_to_yaml(original, &target).unwrap();
352
353 assert!(out.contains("app_id: da_abc123"), "app_id missing:\n{out}");
355
356 assert!(out.starts_with("# Wasmer app configuration\n"), "{out}");
358 assert!(
359 out.contains("name: my-cool-app # human readable name"),
360 "{out}"
361 );
362 assert!(out.contains("# how much memory the app gets"), "{out}");
363 assert!(out.contains(" memory:\n limit: "), "{out}");
364 assert!(out.contains("env:\n LOG_LEVEL: debug"), "{out}");
365 assert!(out.contains("package: .\n\n"), "{out}");
367
368 assert!(!out.contains("512MB"), "{out}");
372 assert!(out.contains("488.3 MiB"), "{out}");
373
374 let reparsed = AppConfigV1::parse_yaml(&out).unwrap();
379 assert_eq!(reparsed.app_id.as_deref(), Some("da_abc123"));
380 assert_eq!(reparsed.name, config.name);
381 assert_eq!(reparsed.owner, config.owner);
382 assert_eq!(reparsed.env, config.env);
383 }
384
385 #[test]
386 fn does_not_add_null_for_absent_key() {
387 let original = r#"kind: wasmer.io/App.v0
388package: .
389"#;
390 let mut target = yaml_serde::Mapping::new();
391 target.insert("kind".into(), "wasmer.io/App.v0".into());
392 target.insert("package".into(), ".".into());
393 target.insert("name".into(), Value::Null);
394
395 let out = apply_app_config_to_yaml(original, &Value::Mapping(target)).unwrap();
396 assert!(!out.contains("name"), "should not add `name: null`: {out}");
397 }
398
399 #[test]
400 fn sets_existing_key_to_null() {
401 let original = r#"kind: wasmer.io/App.v0
402name: my-app
403package: .
404"#;
405 let mut target = yaml_serde::Mapping::new();
406 target.insert("kind".into(), "wasmer.io/App.v0".into());
407 target.insert("name".into(), Value::Null);
408 target.insert("package".into(), ".".into());
409
410 let out = apply_app_config_to_yaml(original, &Value::Mapping(target)).unwrap();
411 assert!(out.contains("name: null"), "{out}");
412 }
413
414 #[test]
415 fn removes_quoted_top_level_key_absent_from_target() {
416 let original = r#""app_id": da_old
417kind: wasmer.io/App.v0
418name: my-app
419package: .
420"#;
421 let target = parse(
422 r#"kind: wasmer.io/App.v0
423name: my-app
424package: .
425"#,
426 );
427
428 let out = apply_app_config_to_yaml(original, &target).unwrap();
429 assert!(!out.contains("app_id"), "app_id should be removed: {out}");
430 assert!(!out.contains("da_old"), "old id should be removed: {out}");
431 assert!(out.contains("kind: wasmer.io/App.v0"));
432 }
433
434 #[test]
437 fn preserves_leading_comment() {
438 let original = "# leading comment\nkind: wasmer.io/App.v0\nowner: alice\n";
439 let target = parse("kind: wasmer.io/App.v0\nowner: bob\n");
440
441 let out = apply_app_config_to_yaml(original, &target).unwrap();
442 assert!(out.starts_with("# leading comment\n"), "{out}");
443 assert!(out.contains("owner: bob"), "{out}");
444 }
445
446 const JOBS_APP_YAML: &str = r#"package: .
448scaling:
449 mode: single_concurrency
450jobs:
451 - name: post-deployment
452 trigger: post-deployment
453 action:
454 execute:
455 package: wasmer/bash
456 command: ls
457 - name: fetch-every-8-min
458 trigger: "*/8 * * * *"
459 action:
460 fetch:
461 path: /
462 timeout: 30s
463 - name: exec-every-min
464 trigger: 1m
465 action:
466 execute:
467 package: wasmer/bash
468 command: ls
469 cli_args:
470 - -lah
471kind: wasmer.io/App.v0
472annotations:
473 shipitcli.com/config:
474 commands: {}
475 php_version: 8.3.29
476 phpix: true
477 phpix_worker_threads: 4
478 port: 8080
479 use_composer: false
480 shipitcli.com/provider: php
481 shipitcli.com/version: 0.21.6
482"#;
483
484 #[test]
487 fn deploy_flow_leaves_indented_jobs_sequence_untouched() {
488 use wasmer_config::app::AppConfigV1;
489
490 let mut config = AppConfigV1::parse_yaml(JOBS_APP_YAML).unwrap();
491 config.app_id = Some("da_test123".to_string());
492 config.name = Some("my-app".to_string());
493 config.owner = Some("someowner".to_string());
494 let target = config.clone().to_yaml_value().unwrap();
495
496 let out = apply_app_config_to_yaml(JOBS_APP_YAML, &target).unwrap();
497
498 assert!(out.contains("jobs:\n - name: post-deployment"), "{out}");
500 assert!(out.contains(" trigger: \"*/8 * * * *\""), "{out}");
501 assert!(out.contains(" cli_args:\n - -lah"), "{out}");
502 assert!(
503 out.contains(" shipitcli.com/config:\n commands: {}"),
504 "{out}"
505 );
506
507 let reparsed = AppConfigV1::parse_yaml(&out).unwrap();
509 assert_eq!(reparsed.app_id.as_deref(), Some("da_test123"));
510 assert_eq!(reparsed.name.as_deref(), Some("my-app"));
511 assert_eq!(reparsed.owner.as_deref(), Some("someowner"));
512 assert_eq!(reparsed.jobs, config.jobs);
513 }
514
515 #[test]
518 fn genuine_sequence_change_produces_valid_yaml() {
519 use wasmer_config::app::AppConfigV1;
520
521 let mut config = AppConfigV1::parse_yaml(JOBS_APP_YAML).unwrap();
522 config.jobs.as_mut().unwrap().remove(2);
523 let target = config.clone().to_yaml_value().unwrap();
524
525 let out = apply_app_config_to_yaml(JOBS_APP_YAML, &target).unwrap();
526
527 let reparsed = AppConfigV1::parse_yaml(&out).unwrap();
528 assert_eq!(reparsed.jobs, config.jobs);
529 }
530
531 #[test]
532 fn preserves_nested_keys_absent_from_target() {
533 let original = r#"kind: wasmer.io/App.v0
534name: my-app
535package: .
536capabilities:
537 memory:
538 limit: 512MB
539 custom_limit_hint: keep-me
540"#;
541 let target = parse(
542 r#"kind: wasmer.io/App.v0
543name: my-app
544package: .
545capabilities:
546 memory:
547 limit: 512MB
548"#,
549 );
550
551 let out = apply_app_config_to_yaml(original, &target).unwrap();
552 assert!(out.contains("custom_limit_hint: keep-me"), "{out}");
553 }
554}