wasmer_cli/utils/
yaml.rs

1//! Format-preserving YAML editing for `app.yaml`.
2//!
3//! Serializing a `serde_yaml::Value` back to a string drops the user's
4//! formatting (comments, key order, blank lines, quoting).
5//! [`apply_app_config_to_yaml`] instead edits the original text in place with
6//! [`yaml_edit`], rewriting only the nodes whose value changed.
7
8use std::str::FromStr;
9
10use anyhow::Context as _;
11use serde_yaml::Value;
12use yaml_edit::{Document, Mapping, YamlNode};
13
14/// Apply `target` onto the original app YAML `text`, preserving the formatting
15/// of everything that did not change.
16///
17/// * A node is rewritten only when its value differs from `target`; comments,
18///   order, blank lines and quoting are otherwise untouched.
19/// * Top-level keys are synced (added, updated, or removed); nested mappings are
20///   merged without removing keys. See [`merge_into_mapping`] for why.
21/// * A `null` in `target` is never added (avoids `name: null` noise); an
22///   existing key can still be set to it.
23///
24/// When the edit cannot be format-preserved (a structured value changed,
25/// see [`set_value`]) or its output does not reparse, falls back to a plain
26/// serialization of `target`.
27pub(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    // Second parse, used to detect which keys changed. `yaml_edit` node text
52    // cannot be reparsed for this: it is dedented on the first line only, so
53    // block values are not valid standalone YAML.
54    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            // Restore the leading comment block that `yaml_edit` drops (see
62            // `leading_trivia`).
63            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        // The document root is not a mapping (or the target is not a mapping).
71        // We have no formatting to preserve in a meaningful way, so fall back to
72        // a plain serialization of the target.
73        _ => Ok(serde_yaml::to_string(target)?),
74    }
75}
76
77/// Recursively merge `target` into `mapping`, using `original` (the semantic
78/// parse of the same subtree) to skip unchanged keys. `remove_missing` (true
79/// only at the top level) drops keys absent from `target`.
80///
81/// Only the top level removes, because `AppConfigV1`'s `#[serde(flatten)] extra`
82/// re-emits unknown top-level keys: a key missing from `target` there was cleared
83/// on purpose (e.g. `app_id`/`name` on owner change). Nested sub-structs have no
84/// such catch-all and drop fields they don't model, so removing nested keys would
85/// delete the user's forward-compatible settings.
86fn 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                // Semantically identical - keep the original formatting.
102                continue;
103            }
104
105            // Recurse so we only touch what changed within the nested mapping.
106            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                    // The key changed type; every nested key counts as changed.
111                    _ => 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            // Don't introduce `key: null` noise for absent keys.
120            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
134/// Set `mapping[key] = value`. Only scalars are supported: `yaml_edit`
135/// incorrectly renders block values when setting them (as of 0.2, #6803), so
136/// structured values error out and the caller falls back to a plain rewrite.
137fn 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                // Unreachable: `serde_yaml` numbers are always i64/u64/f64.
151                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
162/// Return the leading run of blank and comment-only lines at the top of `text`.
163///
164/// `yaml_edit` has a bug that drops this block (as of 0.2), so we
165/// capture it here to splice back after editing.
166fn 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
197/// Extract a scalar mapping key as a string. Non-scalar keys
198/// are skipped.
199fn 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
212/// Convenience wrapper: read the original text from `path`, apply `target`
213/// onto it, and return the format-preserved result. If the file does not exist
214/// or cannot be read, falls back to a plain serialization of `target`.
215pub(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        // Only `owner` changed.
244        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        // app_id dropped (e.g. owner changed).
292        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        // Only owner changes; nested structures are identical.
321        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    /// End-to-end check of the real deploy flow: parse a commented `app.yaml`
342    /// into an [`AppConfigV1`], mutate a field the way `wasmer app deploy` does
343    /// (assign the backend `app_id`), serialize it with `to_yaml_value`, and
344    /// apply it back. The backend assignment must land while every comment,
345    /// blank line, key order and nested structure is left intact.
346    #[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        // Parse exactly like the CLI does.
366        let mut config = AppConfigV1::parse_yaml(original).unwrap();
367        assert!(config.app_id.is_none());
368
369        // The backend assigns an id on first deploy; this is the only change.
370        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        // The new field is added (appended, not reordered into the middle)...
376        assert!(out.contains("app_id: da_abc123"), "app_id missing:\n{out}");
377
378        // ...and all comments, ordering, blank lines and nesting survive.
379        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        // The blank lines between sections are kept.
388        assert!(out.contains("package: .\n\n"), "{out}");
389
390        // The `limit` node is the one exception: `ByteSize` is normalized when it
391        // round-trips through the typed struct (`512MB` -> `488.3 MiB`, the same
392        // amount). Only that node changes; the surrounding formatting does not.
393        assert!(!out.contains("512MB"), "{out}");
394        assert!(out.contains("488.3 MiB"), "{out}");
395
396        // The result is still a valid app config with the expected fields.
397        // (We don't assert full struct equality because `ByteSize`'s own
398        // display round-trip is mildly lossy: 512MB -> "488.3 MiB" -> a value a
399        // few hundred bytes off. That predates this change.)
400        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    /// Fails once `yaml_edit` fixes the [`leading_trivia`] bug, cueing us to
457    /// drop the workaround.
458    #[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    /// The workaround must re-attach the dropped leading comment, even when a
472    /// value changes.
473    #[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    /// The `app.yaml` from issue #6803.
484    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    /// #6803: an unchanged `jobs` was falsely detected as changed and then
522    /// corrupted to `jobs:- name: ...` on rewrite.
523    #[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        // The unchanged blocks keep their exact formatting.
536        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        // The intended changes landed and the file is still a valid config.
545        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    /// A genuine sequence change currently cannot be format-preserved (see
553    /// [`set_value`]); the result must be a valid plain rewrite.
554    #[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}