1use std::str::FromStr;
9
10use anyhow::Context as _;
11use serde_yaml::Value;
12use yaml_edit::{Document, Mapping, YamlNode};
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 serde_yaml::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(serde_yaml::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(serde_yaml::to_string(target)?)
44 }
45 }
46}
47
48fn try_format_preserving_edit(text: &str, target: &Value) -> anyhow::Result<String> {
49 let doc = Document::from_str(text)
50 .map_err(|e| anyhow::anyhow!("could not parse YAML for format-preserving edit: {e}"))?;
51 let original: Value = serde_yaml::from_str(text)
55 .map_err(|e| anyhow::anyhow!("could not parse YAML semantically: {e}"))?;
56
57 match (doc.as_mapping(), target, &original) {
58 (Some(mapping), Value::Mapping(target_mapping), Value::Mapping(original_mapping)) => {
59 merge_into_mapping(&mapping, target_mapping, Some(original_mapping), true)?;
60 let out = doc.to_string();
61 let header = leading_trivia(text);
64 if !header.is_empty() && !out.starts_with(header) {
65 Ok(format!("{header}{out}"))
66 } else {
67 Ok(out)
68 }
69 }
70 _ => Ok(serde_yaml::to_string(target)?),
74 }
75}
76
77fn merge_into_mapping(
87 mapping: &Mapping,
88 target: &serde_yaml::Mapping,
89 original: Option<&serde_yaml::Mapping>,
90 remove_missing: bool,
91) -> anyhow::Result<()> {
92 for (key_value, value) in target {
93 let Some(key) = scalar_key(key_value) else {
94 continue;
95 };
96
97 let original_value = original.and_then(|m| m.get(key_value));
98
99 if mapping.get(&key).is_some() {
100 if original_value == Some(value) {
101 continue;
103 }
104
105 if let (Some(child), Value::Mapping(target_child)) = (mapping.get_mapping(&key), value)
107 {
108 let original_child = match original_value {
109 Some(Value::Mapping(m)) => Some(m),
110 _ => None,
112 };
113 merge_into_mapping(&child, target_child, original_child, false)?;
114 } else {
115 set_value(mapping, &key, value)
116 .with_context(|| format!("could not update app YAML key `{key}`"))?;
117 }
118 } else if !value.is_null() {
119 set_value(mapping, &key, value)
121 .with_context(|| format!("could not add app YAML key `{key}`"))?;
122 }
123 }
124
125 if remove_missing {
126 for key in stale_top_level_keys(mapping, target) {
127 mapping.remove(key.as_str());
128 }
129 }
130
131 Ok(())
132}
133
134fn set_value(mapping: &Mapping, key: &str, value: &Value) -> anyhow::Result<()> {
138 match value {
139 Value::Null => mapping.set(key, yaml_edit::ScalarValue::null()),
140 Value::Bool(b) => mapping.set(key, *b),
141 Value::String(s) => mapping.set(key, s.as_str()),
142 Value::Number(n) => {
143 if let Some(i) = n.as_i64() {
144 mapping.set(key, i);
145 } else if let Some(u) = n.as_u64() {
146 mapping.set(key, u);
147 } else if let Some(f) = n.as_f64() {
148 mapping.set(key, f);
149 } else {
150 anyhow::bail!("cannot represent number for app YAML key `{key}`");
152 }
153 }
154 Value::Sequence(_) | Value::Mapping(_) | Value::Tagged(_) => {
155 anyhow::bail!("cannot format-preserve an edit to structured value `{key}`");
156 }
157 }
158
159 Ok(())
160}
161
162fn leading_trivia(text: &str) -> &str {
167 let mut end = 0;
168 for line in text.split_inclusive('\n') {
169 let trimmed = line.trim_start();
170 if trimmed.is_empty() || trimmed.starts_with('#') {
171 end += line.len();
172 } else {
173 break;
174 }
175 }
176 &text[..end]
177}
178
179fn stale_top_level_keys(mapping: &Mapping, target: &serde_yaml::Mapping) -> Vec<String> {
180 mapping
181 .entries()
182 .filter_map(|entry| {
183 let key = entry.key_node()?;
184 let is_target_key = target
185 .keys()
186 .filter_map(scalar_key)
187 .any(|target_key| key.yaml_eq(&target_key));
188 if is_target_key {
189 None
190 } else {
191 scalar_key_node(&key)
192 }
193 })
194 .collect()
195}
196
197fn scalar_key(key: &Value) -> Option<String> {
200 match key {
201 Value::String(s) => Some(s.clone()),
202 Value::Bool(b) => Some(b.to_string()),
203 Value::Number(n) => Some(n.to_string()),
204 _ => None,
205 }
206}
207
208fn scalar_key_node(key: &YamlNode) -> Option<String> {
209 key.as_scalar().map(|s| s.as_string())
210}
211
212pub(crate) fn apply_app_config_to_yaml_file(
216 path: &std::path::Path,
217 target: &Value,
218) -> anyhow::Result<String> {
219 match std::fs::read_to_string(path) {
220 Ok(text) => apply_app_config_to_yaml(&text, target)
221 .with_context(|| format!("could not edit YAML file '{}'", path.display())),
222 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(serde_yaml::to_string(target)?),
223 Err(e) => Err(e).with_context(|| format!("could not read YAML file '{}'", path.display())),
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 fn parse(s: &str) -> Value {
232 serde_yaml::from_str(s).unwrap()
233 }
234
235 #[test]
236 fn preserves_comments_and_order_on_scalar_change() {
237 let original = r#"# my app
238kind: wasmer.io/App.v0
239name: my-app # the app name
240owner: alice
241package: .
242"#;
243 let target = parse(
245 r#"kind: wasmer.io/App.v0
246name: my-app
247owner: bob
248package: .
249"#,
250 );
251
252 let out = apply_app_config_to_yaml(original, &target).unwrap();
253 assert_eq!(
254 out,
255 r#"# my app
256kind: wasmer.io/App.v0
257name: my-app # the app name
258owner: bob
259package: .
260"#
261 );
262 }
263
264 #[test]
265 fn adds_new_key_without_touching_rest() {
266 let original = r#"kind: wasmer.io/App.v0
267name: my-app
268package: .
269"#;
270 let target = parse(
271 r#"kind: wasmer.io/App.v0
272name: my-app
273package: .
274app_id: da_abc123
275"#,
276 );
277
278 let out = apply_app_config_to_yaml(original, &target).unwrap();
279 assert!(out.contains("app_id: da_abc123"));
280 assert!(out.contains("kind: wasmer.io/App.v0\nname: my-app\npackage: ."));
281 }
282
283 #[test]
284 fn removes_top_level_key_absent_from_target() {
285 let original = r#"kind: wasmer.io/App.v0
286name: my-app
287app_id: da_old
288owner: alice
289package: .
290"#;
291 let target = parse(
293 r#"kind: wasmer.io/App.v0
294name: my-app
295owner: bob
296package: .
297"#,
298 );
299
300 let out = apply_app_config_to_yaml(original, &target).unwrap();
301 assert!(!out.contains("app_id"), "app_id should be removed: {out}");
302 assert!(out.contains("owner: bob"));
303 assert!(out.contains("name: my-app"));
304 }
305
306 #[test]
307 fn preserves_nested_mapping_and_comments_when_unchanged() {
308 let original = r#"kind: wasmer.io/App.v0
309name: my-app
310owner: alice
311package: .
312# capabilities for the app
313capabilities:
314 # memory limit
315 memory:
316 limit: 512MB
317env:
318 FOO: bar
319"#;
320 let target = parse(
322 r#"kind: wasmer.io/App.v0
323name: my-app
324owner: bob
325package: .
326capabilities:
327 memory:
328 limit: 512MB
329env:
330 FOO: bar
331"#,
332 );
333
334 let out = apply_app_config_to_yaml(original, &target).unwrap();
335 assert!(out.contains("# capabilities for the app"));
336 assert!(out.contains("# memory limit"));
337 assert!(out.contains("owner: bob"));
338 assert!(!out.contains("owner: alice"));
339 }
340
341 #[test]
347 fn deploy_flow_preserves_formatting_when_assigning_app_id() {
348 use wasmer_config::app::AppConfigV1;
349
350 let original = r#"# Wasmer app configuration
351kind: wasmer.io/App.v0
352name: my-cool-app # human readable name
353owner: alice
354package: .
355
356# how much memory the app gets
357capabilities:
358 memory:
359 limit: 512MB
360
361env:
362 LOG_LEVEL: debug
363"#;
364
365 let mut config = AppConfigV1::parse_yaml(original).unwrap();
367 assert!(config.app_id.is_none());
368
369 config.app_id = Some("da_abc123".to_string());
371
372 let target = config.clone().to_yaml_value().unwrap();
373 let out = apply_app_config_to_yaml(original, &target).unwrap();
374
375 assert!(out.contains("app_id: da_abc123"), "app_id missing:\n{out}");
377
378 assert!(out.starts_with("# Wasmer app configuration\n"), "{out}");
380 assert!(
381 out.contains("name: my-cool-app # human readable name"),
382 "{out}"
383 );
384 assert!(out.contains("# how much memory the app gets"), "{out}");
385 assert!(out.contains(" memory:\n limit: "), "{out}");
386 assert!(out.contains("env:\n LOG_LEVEL: debug"), "{out}");
387 assert!(out.contains("package: .\n\n"), "{out}");
389
390 assert!(!out.contains("512MB"), "{out}");
394 assert!(out.contains("488.3 MiB"), "{out}");
395
396 let reparsed = AppConfigV1::parse_yaml(&out).unwrap();
401 assert_eq!(reparsed.app_id.as_deref(), Some("da_abc123"));
402 assert_eq!(reparsed.name, config.name);
403 assert_eq!(reparsed.owner, config.owner);
404 assert_eq!(reparsed.env, config.env);
405 }
406
407 #[test]
408 fn does_not_add_null_for_absent_key() {
409 let original = r#"kind: wasmer.io/App.v0
410package: .
411"#;
412 let mut target = serde_yaml::Mapping::new();
413 target.insert("kind".into(), "wasmer.io/App.v0".into());
414 target.insert("package".into(), ".".into());
415 target.insert("name".into(), Value::Null);
416
417 let out = apply_app_config_to_yaml(original, &Value::Mapping(target)).unwrap();
418 assert!(!out.contains("name"), "should not add `name: null`: {out}");
419 }
420
421 #[test]
422 fn sets_existing_key_to_null() {
423 let original = r#"kind: wasmer.io/App.v0
424name: my-app
425package: .
426"#;
427 let mut target = serde_yaml::Mapping::new();
428 target.insert("kind".into(), "wasmer.io/App.v0".into());
429 target.insert("name".into(), Value::Null);
430 target.insert("package".into(), ".".into());
431
432 let out = apply_app_config_to_yaml(original, &Value::Mapping(target)).unwrap();
433 assert!(out.contains("name: null"), "{out}");
434 }
435
436 #[test]
437 fn removes_quoted_top_level_key_absent_from_target() {
438 let original = r#""app_id": da_old
439kind: wasmer.io/App.v0
440name: my-app
441package: .
442"#;
443 let target = parse(
444 r#"kind: wasmer.io/App.v0
445name: my-app
446package: .
447"#,
448 );
449
450 let out = apply_app_config_to_yaml(original, &target).unwrap();
451 assert!(!out.contains("app_id"), "app_id should be removed: {out}");
452 assert!(!out.contains("da_old"), "old id should be removed: {out}");
453 assert!(out.contains("kind: wasmer.io/App.v0"));
454 }
455
456 #[test]
459 fn yaml_edit_still_drops_leading_comments_so_workaround_is_needed() {
460 let src = "# leading comment\nkind: wasmer.io/App.v0\nname: my-app\n";
461 let roundtripped = Document::from_str(src).unwrap().to_string();
462
463 assert!(
464 !roundtripped.contains("# leading comment"),
465 "yaml_edit now preserves leading comments on round-trip; drop the \
466 `leading_trivia` workaround in `apply_app_config_to_yaml` (and this \
467 test).\nGot:\n{roundtripped}"
468 );
469 }
470
471 #[test]
474 fn workaround_reattaches_leading_comment() {
475 let original = "# leading comment\nkind: wasmer.io/App.v0\nowner: alice\n";
476 let target = parse("kind: wasmer.io/App.v0\nowner: bob\n");
477
478 let out = apply_app_config_to_yaml(original, &target).unwrap();
479 assert!(out.starts_with("# leading comment\n"), "{out}");
480 assert!(out.contains("owner: bob"), "{out}");
481 }
482
483 const JOBS_APP_YAML: &str = r#"package: .
485scaling:
486 mode: single_concurrency
487jobs:
488 - name: post-deployment
489 trigger: post-deployment
490 action:
491 execute:
492 package: wasmer/bash
493 command: ls
494 - name: fetch-every-8-min
495 trigger: "*/8 * * * *"
496 action:
497 fetch:
498 path: /
499 timeout: 30s
500 - name: exec-every-min
501 trigger: 1m
502 action:
503 execute:
504 package: wasmer/bash
505 command: ls
506 cli_args:
507 - -lah
508kind: wasmer.io/App.v0
509annotations:
510 shipitcli.com/config:
511 commands: {}
512 php_version: 8.3.29
513 phpix: true
514 phpix_worker_threads: 4
515 port: 8080
516 use_composer: false
517 shipitcli.com/provider: php
518 shipitcli.com/version: 0.21.6
519"#;
520
521 #[test]
524 fn deploy_flow_leaves_indented_jobs_sequence_untouched() {
525 use wasmer_config::app::AppConfigV1;
526
527 let mut config = AppConfigV1::parse_yaml(JOBS_APP_YAML).unwrap();
528 config.app_id = Some("da_test123".to_string());
529 config.name = Some("my-app".to_string());
530 config.owner = Some("someowner".to_string());
531 let target = config.clone().to_yaml_value().unwrap();
532
533 let out = apply_app_config_to_yaml(JOBS_APP_YAML, &target).unwrap();
534
535 assert!(out.contains("jobs:\n - name: post-deployment"), "{out}");
537 assert!(out.contains(" trigger: \"*/8 * * * *\""), "{out}");
538 assert!(out.contains(" cli_args:\n - -lah"), "{out}");
539 assert!(
540 out.contains(" shipitcli.com/config:\n commands: {}"),
541 "{out}"
542 );
543
544 let reparsed = AppConfigV1::parse_yaml(&out).unwrap();
546 assert_eq!(reparsed.app_id.as_deref(), Some("da_test123"));
547 assert_eq!(reparsed.name.as_deref(), Some("my-app"));
548 assert_eq!(reparsed.owner.as_deref(), Some("someowner"));
549 assert_eq!(reparsed.jobs, config.jobs);
550 }
551
552 #[test]
555 fn genuine_sequence_change_produces_valid_yaml() {
556 use wasmer_config::app::AppConfigV1;
557
558 let mut config = AppConfigV1::parse_yaml(JOBS_APP_YAML).unwrap();
559 config.jobs.as_mut().unwrap().remove(2);
560 let target = config.clone().to_yaml_value().unwrap();
561
562 let out = apply_app_config_to_yaml(JOBS_APP_YAML, &target).unwrap();
563
564 let reparsed = AppConfigV1::parse_yaml(&out).unwrap();
565 assert_eq!(reparsed.jobs, config.jobs);
566 }
567
568 #[test]
569 fn preserves_nested_keys_absent_from_target() {
570 let original = r#"kind: wasmer.io/App.v0
571name: my-app
572package: .
573capabilities:
574 memory:
575 limit: 512MB
576 custom_limit_hint: keep-me
577"#;
578 let target = parse(
579 r#"kind: wasmer.io/App.v0
580name: my-app
581package: .
582capabilities:
583 memory:
584 limit: 512MB
585"#,
586 );
587
588 let out = apply_app_config_to_yaml(original, &target).unwrap();
589 assert!(out.contains("custom_limit_hint: keep-me"), "{out}");
590 }
591}