Skip to main content

team_core/
yaml_edit.rs

1//! Comment-preserving YAML edit substrate.
2//!
3//! Wraps [`yaml_edit`] (rowan-backed lossless syntax tree) so callers that
4//! mutate `.team/*.yaml` keep the user's comments, blank-line clusters, and
5//! key ordering intact across save. The previous `serde_yaml::Value`
6//! round-trip stripped all of that on every write — see the dogfood
7//! `.team/projects/teamctl.yaml` regressions on the PR #54 + PR #55
8//! cascades for the class this closes.
9//!
10//! ## Surface
11//!
12//! - [`load`] / [`save`] — IO with `anyhow` context.
13//! - Re-exports of [`yaml_edit::Document`], [`yaml_edit::Mapping`],
14//!   [`yaml_edit::Sequence`], and [`YamlPath`] so callers can drive the
15//!   editor directly for round-trip + leaf updates.
16//! - [`set_nested_mapping`] — bounded line-anchored helper for the one
17//!   pattern yaml-edit 0.2.x can't do natively: insert or replace a
18//!   properly-indented sub-block at a known parent path.
19//!
20//! ## Why the bounded helper
21//!
22//! `yaml_edit::Document::set_path` creates intermediate mappings via
23//! `MappingBuilder::new().build_document().as_mapping()` and inserts them
24//! with `mapping.set(key, &empty_mapping)`. The empty mapping has zero
25//! base-indent, and the resulting nested entries serialize at column 0
26//! instead of indenting under the parent (see `path::set_path_on_mapping`,
27//! registry source line 401-435 of yaml-edit 0.2.1). Filed upstream for
28//! a fix; until then [`set_nested_mapping`] handles the create-nested
29//! pattern via line-anchored splice into the source string before the
30//! Document re-parse. Substrate consumers never see the splice.
31//!
32//! Per-pm scope lock (msg 1969): the helper handles ONE pattern only
33//! ("insert a properly-indented sub-block at a known parent path"). If a
34//! future T-077-E verb needs a different yaml-edit-gap workaround,
35//! escalate; do NOT generalize this helper.
36
37use std::fs;
38use std::path::Path;
39
40use anyhow::{anyhow, Context, Result};
41
42pub use yaml_edit::path::YamlPath;
43pub use yaml_edit::{Document, Mapping, Sequence};
44
45/// Read `path` and parse it as an editable YAML document.
46///
47/// The returned [`Document`] retains the source's comments, blank-line
48/// clusters, and key ordering; mutations applied to it preserve everything
49/// outside the touched range.
50pub fn load(path: &Path) -> Result<Document> {
51    let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
52    raw.parse::<Document>()
53        .with_context(|| format!("parse {}", path.display()))
54}
55
56/// Serialize `doc` and write it to `path`, replacing any previous contents.
57///
58/// `Document`'s `Display` impl emits the underlying syntax tree verbatim,
59/// so untouched regions round-trip byte-for-byte (modulo the upstream
60/// pre-document-trivia limitation noted in the module docs).
61pub fn save(doc: &Document, path: &Path) -> Result<()> {
62    fs::write(path, doc.to_string()).with_context(|| format!("write {}", path.display()))?;
63    Ok(())
64}
65
66/// Replace a top-level scalar value (T-265 PR-a).
67///
68/// Walks `source` line-by-line to find a top-level (indent = 0)
69/// mapping entry whose key is `key`, then rewrites that single line
70/// to `key: value` while leaving every other byte of the document
71/// (comments, blank lines, key order, every nested block) untouched.
72///
73/// **This is a sibling helper to [`set_nested_mapping`], not a
74/// generalization of it** — the per-pm scope-lock at the top of
75/// this module covers the nested-mapping insert gap; the top-level
76/// scalar gap is structurally distinct (no parent path, no
77/// indent-arithmetic, no leaf-block detection) and warrants its
78/// own focused helper rather than bolting onto the nested one.
79/// Used by [`crate::compose::Compose::load`] to auto-rewrite the
80/// legacy `version: 2` integer literal to the semver string
81/// `"2.0.0"` per owner ratification (tg 2989 + 3440).
82///
83/// # Errors
84/// Returns an error if no top-level mapping entry named `key` is
85/// found in the document. Document re-parse failures (which
86/// shouldn't happen for the bounded edit this performs) are
87/// surfaced as errors.
88pub fn set_top_level_scalar(source: &str, key: &str, value: &str) -> Result<String> {
89    let trailing_newline = source.ends_with('\n');
90    let mut out_lines: Vec<String> = Vec::new();
91    let mut rewrote = false;
92    for line in source.lines() {
93        if !rewrote {
94            // Top-level means indent = 0 (no leading whitespace).
95            // Skip blank lines and comment lines (full-line `#`).
96            let trimmed = line.trim_start();
97            let indent = line.len() - trimmed.len();
98            if indent == 0 && !trimmed.is_empty() && !trimmed.starts_with('#') {
99                if let Some((found_key, _rest)) = trimmed.split_once(':') {
100                    if found_key == key {
101                        out_lines.push(format!("{key}: {value}"));
102                        rewrote = true;
103                        continue;
104                    }
105                }
106            }
107        }
108        out_lines.push(line.to_string());
109    }
110    if !rewrote {
111        return Err(anyhow!(
112            "set_top_level_scalar: no top-level key `{key}` found in document"
113        ));
114    }
115    let mut joined = out_lines.join("\n");
116    if trailing_newline && !joined.ends_with('\n') {
117        joined.push('\n');
118    }
119    // T-265 PR-a: deliberately return the spliced String directly
120    // rather than round-tripping through `Document::parse` →
121    // `Document::to_string` — the yaml_edit 0.2.x upstream has a
122    // pre-document-trivia limitation that strips leading comments
123    // on round-trip, and the splice already preserves them
124    // line-perfectly. The caller writes this straight to disk.
125    Ok(joined)
126}
127
128/// Insert or replace a nested mapping at the given parent path.
129///
130/// `parent_path` is a sequence of mapping keys descending from the root.
131/// All but the last key must resolve to a mapping that the helper can
132/// either find in the source or create alongside its existing siblings.
133/// The last key (the leaf) is the mapping the caller wants to upsert.
134/// `value_pairs` becomes the body of that leaf mapping.
135///
136/// Existing siblings of the leaf — and existing siblings of any
137/// intermediate the helper has to create — are preserved with their
138/// comments and ordering intact. If the leaf mapping already exists,
139/// it is replaced wholesale by `value_pairs`. Other adapters under the
140/// same parent (e.g. `discord:` next to `telegram:`) survive.
141///
142/// # Errors
143/// Returns an error if `parent_path` is empty or if the first key is
144/// not a top-level mapping in the document.
145pub fn set_nested_mapping(
146    doc: Document,
147    parent_path: &[&str],
148    value_pairs: &[(&str, &str)],
149) -> Result<Document> {
150    if parent_path.is_empty() {
151        return Err(anyhow!("set_nested_mapping: parent_path must not be empty"));
152    }
153    let source = doc.to_string();
154    let edited = splice_nested_mapping(&source, parent_path, value_pairs)?;
155    edited
156        .parse::<Document>()
157        .with_context(|| "re-parse spliced YAML")
158}
159
160/// Line-anchored splice: walk `source` to find the deepest existing
161/// ancestor of `path`, then insert (or replace) the missing tail and the
162/// leaf body at the right indent.
163fn splice_nested_mapping(
164    source: &str,
165    path: &[&str],
166    value_pairs: &[(&str, &str)],
167) -> Result<String> {
168    let lines: Vec<&str> = source.lines().collect();
169    let trailing_newline = source.ends_with('\n');
170
171    // Walk the path top-down, tracking the (line, indent) of each existing
172    // ancestor. Stop at the first missing component.
173    let mut current_indent: usize = 0;
174    let mut search_start: usize = 0;
175    let mut search_end: usize = lines.len();
176    let mut existing_depth: usize = 0;
177    let mut leaf_replace_range: Option<(usize, usize, usize)> = None; // (start_line, end_line_exclusive, leaf_indent)
178
179    for (depth, key) in path.iter().enumerate() {
180        let parent_indent = current_indent;
181        let child_indent_min = if depth == 0 { 0 } else { parent_indent + 1 };
182        match find_key_in_block(&lines, search_start, search_end, key, child_indent_min) {
183            Some((line_idx, key_indent)) => {
184                existing_depth = depth + 1;
185                current_indent = key_indent;
186                let block_end = block_end_after(&lines, line_idx, key_indent);
187                if depth == path.len() - 1 {
188                    leaf_replace_range = Some((line_idx, block_end, key_indent));
189                } else {
190                    search_start = line_idx + 1;
191                    search_end = block_end;
192                }
193            }
194            None => break,
195        }
196    }
197
198    if existing_depth == 0 {
199        return Err(anyhow!(
200            "set_nested_mapping: top-level key `{}` not found",
201            path[0]
202        ));
203    }
204
205    // Build the replacement / insertion block.
206    let insert_indent = if existing_depth == path.len() {
207        // Leaf already exists; reuse its indent.
208        leaf_replace_range.expect("leaf existed").2
209    } else {
210        // First missing component lands one level deeper than its parent.
211        current_indent + 2
212    };
213
214    let missing_tail = &path[existing_depth..];
215    let mut block_lines: Vec<String> = Vec::new();
216    let mut indent = insert_indent;
217    for key in missing_tail {
218        block_lines.push(format!("{:indent$}{key}:", "", indent = indent, key = key));
219        indent += 2;
220    }
221    // Leaf-relative value indent. When the leaf was missing, the
222    // `missing_tail` loop already emitted the leaf key and advanced
223    // `indent` one level past it, so `indent` is the correct child
224    // level. When the leaf already existed, `missing_tail` was empty —
225    // `indent` still equals the leaf's own indent, so values must sit
226    // one level deeper than the re-emitted leaf key. Without this they
227    // serialize as siblings of the leaf, silently nulling it (#311:
228    // `bot setup` re-running against an essentials team — whose
229    // scaffold pre-wires `interfaces.telegram` — produced `telegram:`
230    // with sibling `bot_token_env:`, so `agent.telegram()` parsed
231    // `None` and the Telegram bridge never started).
232    let value_indent = if existing_depth == path.len() {
233        insert_indent + 2
234    } else {
235        indent
236    };
237    if existing_depth == path.len() {
238        // We're replacing an existing leaf — emit the leaf key line too.
239        block_lines.push(format!(
240            "{:indent$}{key}:",
241            "",
242            indent = insert_indent,
243            key = path[path.len() - 1]
244        ));
245    }
246    for (k, v) in value_pairs {
247        block_lines.push(format!(
248            "{:indent$}{k}: {v}",
249            "",
250            indent = value_indent,
251            k = k,
252            v = v
253        ));
254    }
255
256    // Splice: replace the leaf-block range or insert at the parent's
257    // block end.
258    let mut out_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
259    if let Some((start, end, _)) = leaf_replace_range {
260        out_lines.splice(start..end, block_lines);
261    } else {
262        // Insert at the end of the deepest-existing-ancestor's block.
263        // search_end is that block's end (exclusive); insert there.
264        out_lines.splice(search_end..search_end, block_lines);
265    }
266
267    let mut joined = out_lines.join("\n");
268    if trailing_newline && !joined.ends_with('\n') {
269        joined.push('\n');
270    }
271    Ok(joined)
272}
273
274/// Within `lines[start..end]`, find a mapping key whose indent is `>=
275/// min_indent` AND is the direct-child indent of its parent (i.e. the
276/// minimum indent appearing in this slice that is `>= min_indent`).
277/// Returns `(line_idx, indent)`.
278fn find_key_in_block(
279    lines: &[&str],
280    start: usize,
281    end: usize,
282    key: &str,
283    min_indent: usize,
284) -> Option<(usize, usize)> {
285    // First pass: find the smallest indent in this slice that's >= min_indent
286    // and belongs to a mapping key line (`<indent>foo:` with foo non-empty).
287    // That defines "direct children" of the parent.
288    let mut child_indent: Option<usize> = None;
289    for line in lines.iter().take(end).skip(start) {
290        if let Some((indent, _)) = parse_mapping_key_line(line) {
291            if indent >= min_indent {
292                child_indent = Some(child_indent.map_or(indent, |c| c.min(indent)));
293            }
294        }
295    }
296    let child_indent = child_indent?;
297
298    // Second pass: find the named key at exactly child_indent.
299    for (i, line) in lines.iter().enumerate().take(end).skip(start) {
300        if let Some((indent, found_key)) = parse_mapping_key_line(line) {
301            if indent == child_indent && found_key == key {
302                return Some((i, indent));
303            }
304        }
305    }
306    None
307}
308
309/// Returns `(indent, key)` if `line` is a `<indent>key:` mapping entry —
310/// i.e. starts with spaces, has a non-empty unquoted-non-list key, and
311/// ends `:` (possibly followed by whitespace + an inline value).
312///
313/// Conservative: this helper does NOT recognise quoted keys, flow
314/// mappings, or anchors. The verbs T-077-E targets stick to the canonical
315/// block-style YAML in `examples/*/.team/`, which uses none of those.
316/// If a future verb needs broader coverage, escalate per the pm-locked
317/// scope rule in the module docs — do not silently extend here.
318fn parse_mapping_key_line(line: &str) -> Option<(usize, &str)> {
319    let indent = line.len() - line.trim_start().len();
320    let trimmed = line.trim_start();
321    if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
322        return None;
323    }
324    let colon_idx = trimmed.find(':')?;
325    let key = &trimmed[..colon_idx];
326    if key.is_empty() {
327        return None;
328    }
329    // Reject lines like "key: value: tail" — only recognise where the key
330    // contains no colon. This keeps us out of inline-value territory.
331    if key.contains(':') {
332        return None;
333    }
334    // After ':' must be end-of-line OR whitespace (then either end-of-line
335    // for a parent mapping, or value).
336    let after = &trimmed[colon_idx + 1..];
337    if !after.is_empty() && !after.starts_with(char::is_whitespace) {
338        // e.g. `http://...` — colon is part of a value, not a key separator.
339        return None;
340    }
341    Some((indent, key))
342}
343
344/// End (exclusive) of the block belonging to a key at line `key_line` with
345/// indent `key_indent`. The block includes every following line whose
346/// effective indent is `> key_indent` plus interleaved blank/comment
347/// lines, stopping at the first line with indent `<= key_indent` that is
348/// itself a mapping key (or end of file).
349fn block_end_after(lines: &[&str], key_line: usize, key_indent: usize) -> usize {
350    for (i, line) in lines.iter().enumerate().skip(key_line + 1) {
351        let trimmed = line.trim_start();
352        if trimmed.is_empty() || trimmed.starts_with('#') {
353            continue;
354        }
355        let indent = line.len() - trimmed.len();
356        if indent <= key_indent {
357            return i;
358        }
359    }
360    lines.len()
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    const COMMENTED_FIXTURE: &str = "\
368version: 2
369
370# managers block: each manager is a long-running agent.
371managers:
372  pm:
373    runtime: claude-code  # canonical runtime
374    role_prompt: roles/pm.md
375    # interfaces lands here once `teamctl bot setup` runs
376  eng_lead:
377    runtime: claude-code
378    role_prompt: roles/eng_lead.md
379
380# trailing footer
381";
382
383    #[test]
384    fn round_trip_preserves_byte_for_byte() {
385        let dir = tempfile::tempdir().unwrap();
386        let path = dir.path().join("fixture.yaml");
387        fs::write(&path, COMMENTED_FIXTURE).unwrap();
388
389        let doc = load(&path).unwrap();
390        save(&doc, &path).unwrap();
391
392        let after = fs::read_to_string(&path).unwrap();
393        assert_eq!(
394            after, COMMENTED_FIXTURE,
395            "load → save without mutation must be byte-perfect"
396        );
397    }
398
399    #[test]
400    fn mutation_preserves_comments() {
401        let dir = tempfile::tempdir().unwrap();
402        let path = dir.path().join("fixture.yaml");
403        fs::write(&path, COMMENTED_FIXTURE).unwrap();
404
405        let doc = load(&path).unwrap();
406        let doc = set_nested_mapping(
407            doc,
408            &["managers", "pm", "interfaces", "telegram"],
409            &[("bot_token_env", "PM_TOKEN"), ("chat_ids_env", "PM_CHATS")],
410        )
411        .unwrap();
412        save(&doc, &path).unwrap();
413
414        let after = fs::read_to_string(&path).unwrap();
415
416        assert!(
417            after.contains("# managers block: each manager is a long-running agent."),
418            "block comment dropped:\n{after}"
419        );
420        assert!(
421            after.contains("# canonical runtime"),
422            "trailing line comment dropped:\n{after}"
423        );
424        assert!(
425            after.contains("# trailing footer"),
426            "footer comment dropped:\n{after}"
427        );
428        assert!(
429            after.contains("    interfaces:"),
430            "interfaces not properly indented under pm:\n{after}"
431        );
432        assert!(
433            after.contains("      telegram:"),
434            "telegram not properly indented under interfaces:\n{after}"
435        );
436        assert!(
437            after.contains("        bot_token_env: PM_TOKEN"),
438            "leaf not properly indented:\n{after}"
439        );
440        assert!(after.contains("        chat_ids_env: PM_CHATS"));
441
442        // Key ordering preserved on unchanged sections.
443        let pm_idx = after.find("pm:").expect("pm key");
444        let eng_idx = after.find("eng_lead:").expect("eng_lead key");
445        assert!(pm_idx < eng_idx, "manager key order swapped:\n{after}");
446
447        // Blank line separator between pm and eng_lead survives.
448        assert!(
449            after.contains("\n  eng_lead:"),
450            "eng_lead boundary broken:\n{after}"
451        );
452    }
453
454    /// Regression test for the dogfood-yaml class that hit PR #54 + PR #55.
455    /// Saving through this substrate doesn't strip the comments the user
456    /// put in their project YAML.
457    #[test]
458    fn save_does_not_strip_existing_comments() {
459        let dir = tempfile::tempdir().unwrap();
460        let path = dir.path().join("oss-shape.yaml");
461        let fixture = "\
462version: 2
463
464project:
465  id: oss
466  name: OSS Maintainer
467  cwd: ./workspace
468
469# Hub-and-spoke: maintainer is the only manager; workers fan out below.
470managers:
471  maintainer:
472    runtime: claude-code
473    role_prompt: roles/maintainer.md
474    # `teamctl bot setup` writes the interfaces.telegram block here.
475
476workers:
477  bug_fix:
478    runtime: claude-code  # workers default to sonnet
479    reports_to: maintainer
480";
481        fs::write(&path, fixture).unwrap();
482
483        let doc = load(&path).unwrap();
484        let doc = set_nested_mapping(
485            doc,
486            &["managers", "maintainer", "interfaces", "telegram"],
487            &[
488                ("bot_token_env", "TEAMCTL_TG_MAINTAINER_TOKEN"),
489                ("chat_ids_env", "TEAMCTL_TG_MAINTAINER_CHATS"),
490            ],
491        )
492        .unwrap();
493        save(&doc, &path).unwrap();
494
495        let after = fs::read_to_string(&path).unwrap();
496        assert!(
497            after.contains(
498                "# Hub-and-spoke: maintainer is the only manager; workers fan out below."
499            ),
500            "block comment dropped — regression class still open:\n{after}"
501        );
502        assert!(
503            after.contains("# `teamctl bot setup` writes the interfaces.telegram block here."),
504            "inline comment dropped:\n{after}"
505        );
506        assert!(
507            after.contains("# workers default to sonnet"),
508            "trailing line comment dropped:\n{after}"
509        );
510        assert!(after.contains("    interfaces:"));
511        assert!(after.contains("      telegram:"));
512        assert!(after.contains("        bot_token_env: TEAMCTL_TG_MAINTAINER_TOKEN"));
513        assert!(after.contains("        chat_ids_env: TEAMCTL_TG_MAINTAINER_CHATS"));
514    }
515
516    /// Idempotency: re-running set_nested_mapping with the same path
517    /// replaces the leaf in place rather than appending a duplicate.
518    /// Sibling adapters under the same parent survive.
519    #[test]
520    fn idempotent_replace_preserves_siblings() {
521        let dir = tempfile::tempdir().unwrap();
522        let path = dir.path().join("siblings.yaml");
523        let fixture = "\
524version: 2
525managers:
526  pm:
527    runtime: claude-code
528    interfaces:
529      discord:
530        bot_token_env: PM_DISCORD_TOKEN
531      telegram:
532        bot_token_env: OLD_TOKEN
533        chat_ids_env: OLD_CHATS
534";
535        fs::write(&path, fixture).unwrap();
536
537        let doc = load(&path).unwrap();
538        let doc = set_nested_mapping(
539            doc,
540            &["managers", "pm", "interfaces", "telegram"],
541            &[
542                ("bot_token_env", "NEW_TOKEN"),
543                ("chat_ids_env", "NEW_CHATS"),
544            ],
545        )
546        .unwrap();
547        save(&doc, &path).unwrap();
548
549        let after = fs::read_to_string(&path).unwrap();
550        assert_eq!(
551            after.matches("telegram:").count(),
552            1,
553            "duplicate telegram block:\n{after}"
554        );
555        assert_eq!(
556            after.matches("discord:").count(),
557            1,
558            "discord sibling lost:\n{after}"
559        );
560        assert!(
561            after.contains("PM_DISCORD_TOKEN"),
562            "discord adapter contents lost:\n{after}"
563        );
564        assert!(after.contains("NEW_TOKEN"));
565        assert!(after.contains("NEW_CHATS"));
566        assert!(!after.contains("OLD_TOKEN"));
567        assert!(!after.contains("OLD_CHATS"));
568    }
569
570    /// #311 root cause: replacing an **already-existing** leaf must nest
571    /// the value pairs one level *under* the re-emitted leaf key, not at
572    /// the leaf's own indent. The string-only `idempotent_replace_*`
573    /// test missed this because `contains("NEW_TOKEN")` is true even
574    /// when the pair serializes as a sibling of `telegram:` (nulling the
575    /// leaf). This asserts the parsed structure, not substrings: the
576    /// essentials scaffold pre-wires `interfaces.telegram`, so every
577    /// `bot setup` on a fresh team hit this replace path and the bridge
578    /// never saw a telegram block.
579    #[test]
580    fn replace_existing_leaf_nests_values_under_it() {
581        let dir = tempfile::tempdir().unwrap();
582        let path = dir.path().join("prewired.yaml");
583        // Mirrors the shipped `essentials` ops.yaml: a pre-wired
584        // telegram leaf the wizard re-writes verbatim.
585        let fixture = "\
586version: 2
587managers:
588  builder:
589    runtime: claude-code
590    interfaces:
591      telegram:
592        bot_token_env: TEAMCTL_TG_BUILDER_TOKEN
593        chat_ids_env: TEAMCTL_TG_BUILDER_CHATS
594";
595        fs::write(&path, fixture).unwrap();
596
597        let doc = load(&path).unwrap();
598        let doc = set_nested_mapping(
599            doc,
600            &["managers", "builder", "interfaces", "telegram"],
601            &[
602                ("bot_token_env", "TEAMCTL_TG_BUILDER_TOKEN"),
603                ("chat_ids_env", "TEAMCTL_TG_BUILDER_CHATS"),
604            ],
605        )
606        .unwrap();
607        save(&doc, &path).unwrap();
608
609        let after = fs::read_to_string(&path).unwrap();
610        let v: serde_yaml::Value = serde_yaml::from_str(&after)
611            .unwrap_or_else(|e| panic!("re-spliced YAML must parse: {e}\n{after}"));
612        let tg = &v["managers"]["builder"]["interfaces"]["telegram"];
613        assert!(
614            tg.is_mapping(),
615            "telegram leaf must remain a mapping, not be nulled by sibling pairs:\n{after}"
616        );
617        assert_eq!(
618            tg["bot_token_env"].as_str(),
619            Some("TEAMCTL_TG_BUILDER_TOKEN"),
620            "bot_token_env must be nested under telegram:\n{after}"
621        );
622        assert_eq!(
623            tg["chat_ids_env"].as_str(),
624            Some("TEAMCTL_TG_BUILDER_CHATS"),
625            "chat_ids_env must be nested under telegram:\n{after}"
626        );
627    }
628
629    // T-265 PR-a: set_top_level_scalar sibling helper.
630
631    #[test]
632    fn set_top_level_scalar_replaces_integer_with_quoted_string() {
633        // The headline use case: legacy `version: 2` integer →
634        // canonical `version: "2.0.0"` semver string.
635        let src = "\
636# leading comment
637version: 2
638broker:
639  type: sqlite
640";
641        let edited = set_top_level_scalar(src, "version", "\"2.0.0\"").unwrap();
642        let out = edited;
643        assert!(
644            out.contains("version: \"2.0.0\""),
645            "rewrite missing:\n{out}"
646        );
647        assert!(
648            !out.contains("\nversion: 2\n"),
649            "old literal survived:\n{out}"
650        );
651        // Comment + other top-level keys preserved.
652        assert!(out.contains("# leading comment"));
653        assert!(out.contains("broker:"));
654        assert!(out.contains("type: sqlite"));
655    }
656
657    #[test]
658    fn set_top_level_scalar_is_idempotent() {
659        let src = "version: \"2.0.0\"\nbroker:\n  type: sqlite\n";
660        let edited = set_top_level_scalar(src, "version", "\"2.0.0\"").unwrap();
661        let out = edited;
662        assert_eq!(
663            out.matches("version:").count(),
664            1,
665            "no duplicate version line:\n{out}"
666        );
667        assert!(out.contains("version: \"2.0.0\""));
668    }
669
670    #[test]
671    fn set_top_level_scalar_errors_on_missing_key() {
672        let src = "broker:\n  type: sqlite\n";
673        let err = set_top_level_scalar(src, "version", "\"2.0.0\"").expect_err("missing key");
674        assert!(
675            err.to_string().contains("no top-level key `version` found"),
676            "error must name the missing key: {err}"
677        );
678    }
679
680    #[test]
681    fn set_top_level_scalar_only_touches_top_level_key() {
682        // A nested `version:` inside another mapping must NOT be
683        // rewritten — only the top-level one. Crucial for
684        // projects/*.yaml which is OUT of PR-a's scope (still has
685        // `version: u32`) but might be referenced inside a future
686        // nested block.
687        let src = "\
688version: 2
689nested:
690  version: 99
691  other: ok
692";
693        let edited = set_top_level_scalar(src, "version", "\"2.0.0\"").unwrap();
694        let out = edited;
695        assert!(
696            out.contains("version: \"2.0.0\""),
697            "top-level rewritten:\n{out}"
698        );
699        assert!(
700            out.contains("  version: 99"),
701            "nested version: 99 must be left alone:\n{out}"
702        );
703    }
704}