Skip to main content

wasmer_cli/utils/
yaml.rs

1//! Format-preserving YAML editing for `app.yaml`.
2//!
3//! Serializing a `yaml_serde::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 yaml_edit::{Mapping, YamlFile, YamlNode};
12use yaml_serde::Value;
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 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    // Second parse, used to detect which keys changed. `yaml_edit` node text
55    // cannot be reparsed for this: it is dedented on the first line only, so
56    // block values are not valid standalone YAML.
57    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        // The document root is not a mapping (or the target is not a mapping).
66        // We have no formatting to preserve in a meaningful way, so fall back to
67        // a plain serialization of the target.
68        _ => Ok(yaml_serde::to_string(target)?),
69    }
70}
71
72/// Recursively merge `target` into `mapping`, using `original` (the semantic
73/// parse of the same subtree) to skip unchanged keys. `remove_missing` (true
74/// only at the top level) drops keys absent from `target`.
75///
76/// Only the top level removes, because `AppConfigV1`'s `#[serde(flatten)] extra`
77/// re-emits unknown top-level keys: a key missing from `target` there was cleared
78/// on purpose (e.g. `app_id`/`name` on owner change). Nested sub-structs have no
79/// such catch-all and drop fields they don't model, so removing nested keys would
80/// delete the user's forward-compatible settings.
81fn 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                // Semantically identical - keep the original formatting.
97                continue;
98            }
99
100            // Recurse so we only touch what changed within the nested mapping.
101            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                    // The key changed type; every nested key counts as changed.
106                    _ => 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            // Don't introduce `key: null` noise for absent keys.
115            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
129/// Set `mapping[key] = value`. Only scalars are supported: `yaml_edit`
130/// incorrectly renders block values when setting them (as of 0.2, #6803), so
131/// structured values error out and the caller falls back to a plain rewrite.
132fn 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                // Unreachable: `yaml_serde` numbers are always i64/u64/f64.
146                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
175/// Extract a scalar mapping key as a string. Non-scalar keys
176/// are skipped.
177fn 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
190/// Convenience wrapper: read the original text from `path`, apply `target`
191/// onto it, and return the format-preserved result. If the file does not exist
192/// or cannot be read, falls back to a plain serialization of `target`.
193pub(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        // Only `owner` changed.
222        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        // app_id dropped (e.g. owner changed).
270        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        // Only owner changes; nested structures are identical.
299        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    /// End-to-end check of the real deploy flow: parse a commented `app.yaml`
320    /// into an [`AppConfigV1`], mutate a field the way `wasmer app deploy` does
321    /// (assign the backend `app_id`), serialize it with `to_yaml_value`, and
322    /// apply it back. The backend assignment must land while every comment,
323    /// blank line, key order and nested structure is left intact.
324    #[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        // Parse exactly like the CLI does.
344        let mut config = AppConfigV1::parse_yaml(original).unwrap();
345        assert!(config.app_id.is_none());
346
347        // The backend assigns an id on first deploy; this is the only change.
348        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        // The new field is added (appended, not reordered into the middle)...
354        assert!(out.contains("app_id: da_abc123"), "app_id missing:\n{out}");
355
356        // ...and all comments, ordering, blank lines and nesting survive.
357        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        // The blank lines between sections are kept.
366        assert!(out.contains("package: .\n\n"), "{out}");
367
368        // The `limit` node is the one exception: `ByteSize` is normalized when it
369        // round-trips through the typed struct (`512MB` -> `488.3 MiB`, the same
370        // amount). Only that node changes; the surrounding formatting does not.
371        assert!(!out.contains("512MB"), "{out}");
372        assert!(out.contains("488.3 MiB"), "{out}");
373
374        // The result is still a valid app config with the expected fields.
375        // (We don't assert full struct equality because `ByteSize`'s own
376        // display round-trip is mildly lossy: 512MB -> "488.3 MiB" -> a value a
377        // few hundred bytes off. That predates this change.)
378        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    /// Stream-level comments must remain attached to the YAML file when a value
435    /// changes in its document.
436    #[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    /// The `app.yaml` from issue #6803.
447    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    /// #6803: an unchanged `jobs` was falsely detected as changed and then
485    /// corrupted to `jobs:- name: ...` on rewrite.
486    #[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        // The unchanged blocks keep their exact formatting.
499        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        // The intended changes landed and the file is still a valid config.
508        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    /// A genuine sequence change currently cannot be format-preserved (see
516    /// [`set_value`]); the result must be a valid plain rewrite.
517    #[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}