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.
349///
350/// At end of file (no dedented sibling bounds the block) the end is the
351/// leaf's own last content line, *not* `lines.len()`: trailing comment
352/// lines that follow the file-final block are trivia that belong to the
353/// file, not the leaf, and must survive a leaf replace — the whole point of
354/// the comment-preserving substrate (#319). Interior trivia between the
355/// leaf's content lines stays inside the block as before.
356///
357/// Caveat (pre-existing, not introduced by this fix): a run of *pure*
358/// trailing blank lines at EOF can still lose one line to the
359/// `lines()`/`join("\n")` round-trip in `splice_nested_mapping`, which
360/// cannot distinguish `\n\n\n` from `\n\n`. Comments survive because they
361/// are non-empty, and blanks adjacent to a surviving comment survive too;
362/// only a terminal pure-blank run is affected. This bound is still a strict
363/// improvement — before it, *all* trailing trivia after a file-final leaf
364/// (comments included) was deleted by the splice.
365fn block_end_after(lines: &[&str], key_line: usize, key_indent: usize) -> usize {
366    // The leaf key line itself is always block content; deeper-indented
367    // lines extend it. Track the last such content line so trailing trivia
368    // at EOF is excluded from the returned range.
369    let mut last_content = key_line;
370    for (i, line) in lines.iter().enumerate().skip(key_line + 1) {
371        let trimmed = line.trim_start();
372        if trimmed.is_empty() || trimmed.starts_with('#') {
373            continue;
374        }
375        let indent = line.len() - trimmed.len();
376        if indent <= key_indent {
377            return i;
378        }
379        last_content = i;
380    }
381    last_content + 1
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    const COMMENTED_FIXTURE: &str = "\
389version: 2
390
391# managers block: each manager is a long-running agent.
392managers:
393  pm:
394    runtime: claude-code  # canonical runtime
395    role_prompt: roles/pm.md
396    # interfaces lands here once `teamctl bot setup` runs
397  eng_lead:
398    runtime: claude-code
399    role_prompt: roles/eng_lead.md
400
401# trailing footer
402";
403
404    #[test]
405    fn round_trip_preserves_byte_for_byte() {
406        let dir = tempfile::tempdir().unwrap();
407        let path = dir.path().join("fixture.yaml");
408        fs::write(&path, COMMENTED_FIXTURE).unwrap();
409
410        let doc = load(&path).unwrap();
411        save(&doc, &path).unwrap();
412
413        let after = fs::read_to_string(&path).unwrap();
414        assert_eq!(
415            after, COMMENTED_FIXTURE,
416            "load → save without mutation must be byte-perfect"
417        );
418    }
419
420    #[test]
421    fn mutation_preserves_comments() {
422        let dir = tempfile::tempdir().unwrap();
423        let path = dir.path().join("fixture.yaml");
424        fs::write(&path, COMMENTED_FIXTURE).unwrap();
425
426        let doc = load(&path).unwrap();
427        let doc = set_nested_mapping(
428            doc,
429            &["managers", "pm", "interfaces", "telegram"],
430            &[("bot_token_env", "PM_TOKEN"), ("chat_ids_env", "PM_CHATS")],
431        )
432        .unwrap();
433        save(&doc, &path).unwrap();
434
435        let after = fs::read_to_string(&path).unwrap();
436
437        assert!(
438            after.contains("# managers block: each manager is a long-running agent."),
439            "block comment dropped:\n{after}"
440        );
441        assert!(
442            after.contains("# canonical runtime"),
443            "trailing line comment dropped:\n{after}"
444        );
445        assert!(
446            after.contains("# trailing footer"),
447            "footer comment dropped:\n{after}"
448        );
449        assert!(
450            after.contains("    interfaces:"),
451            "interfaces not properly indented under pm:\n{after}"
452        );
453        assert!(
454            after.contains("      telegram:"),
455            "telegram not properly indented under interfaces:\n{after}"
456        );
457        assert!(
458            after.contains("        bot_token_env: PM_TOKEN"),
459            "leaf not properly indented:\n{after}"
460        );
461        assert!(after.contains("        chat_ids_env: PM_CHATS"));
462
463        // Key ordering preserved on unchanged sections.
464        let pm_idx = after.find("pm:").expect("pm key");
465        let eng_idx = after.find("eng_lead:").expect("eng_lead key");
466        assert!(pm_idx < eng_idx, "manager key order swapped:\n{after}");
467
468        // Blank line separator between pm and eng_lead survives.
469        assert!(
470            after.contains("\n  eng_lead:"),
471            "eng_lead boundary broken:\n{after}"
472        );
473    }
474
475    /// Regression test for the dogfood-yaml class that hit PR #54 + PR #55.
476    /// Saving through this substrate doesn't strip the comments the user
477    /// put in their project YAML.
478    #[test]
479    fn save_does_not_strip_existing_comments() {
480        let dir = tempfile::tempdir().unwrap();
481        let path = dir.path().join("oss-shape.yaml");
482        let fixture = "\
483version: 2
484
485project:
486  id: oss
487  name: OSS Maintainer
488  cwd: ./workspace
489
490# Hub-and-spoke: maintainer is the only manager; workers fan out below.
491managers:
492  maintainer:
493    runtime: claude-code
494    role_prompt: roles/maintainer.md
495    # `teamctl bot setup` writes the interfaces.telegram block here.
496
497workers:
498  bug_fix:
499    runtime: claude-code  # workers default to sonnet
500    reports_to: maintainer
501";
502        fs::write(&path, fixture).unwrap();
503
504        let doc = load(&path).unwrap();
505        let doc = set_nested_mapping(
506            doc,
507            &["managers", "maintainer", "interfaces", "telegram"],
508            &[
509                ("bot_token_env", "TEAMCTL_TG_MAINTAINER_TOKEN"),
510                ("chat_ids_env", "TEAMCTL_TG_MAINTAINER_CHATS"),
511            ],
512        )
513        .unwrap();
514        save(&doc, &path).unwrap();
515
516        let after = fs::read_to_string(&path).unwrap();
517        assert!(
518            after.contains(
519                "# Hub-and-spoke: maintainer is the only manager; workers fan out below."
520            ),
521            "block comment dropped — regression class still open:\n{after}"
522        );
523        assert!(
524            after.contains("# `teamctl bot setup` writes the interfaces.telegram block here."),
525            "inline comment dropped:\n{after}"
526        );
527        assert!(
528            after.contains("# workers default to sonnet"),
529            "trailing line comment dropped:\n{after}"
530        );
531        assert!(after.contains("    interfaces:"));
532        assert!(after.contains("      telegram:"));
533        assert!(after.contains("        bot_token_env: TEAMCTL_TG_MAINTAINER_TOKEN"));
534        assert!(after.contains("        chat_ids_env: TEAMCTL_TG_MAINTAINER_CHATS"));
535    }
536
537    /// #319: when the replaced leaf is the file-final content block,
538    /// followed only by comment and/or blank trivia, `block_end_after`
539    /// must not absorb that trailing trivia into the splice range — the
540    /// trailing comment/blank lines have to survive a leaf replace just as
541    /// they do mid-file. Latent in production because a dedented sibling
542    /// (e.g. a `workers:` block after `managers:`) usually bounds the leaf;
543    /// hand-authored files that end on the replaced leaf hit it.
544    #[test]
545    fn replace_file_final_leaf_preserves_trailing_trivia() {
546        let dir = tempfile::tempdir().unwrap();
547        let path = dir.path().join("file-final.yaml");
548        let fixture = "\
549version: 2
550managers:
551  pm:
552    runtime: claude-code
553    interfaces:
554      telegram:
555        bot_token_env: OLD_TOKEN
556        chat_ids_env: OLD_CHATS
557
558# operator note: keep this trailing footer
559";
560        fs::write(&path, fixture).unwrap();
561
562        let doc = load(&path).unwrap();
563        let doc = set_nested_mapping(
564            doc,
565            &["managers", "pm", "interfaces", "telegram"],
566            &[
567                ("bot_token_env", "NEW_TOKEN"),
568                ("chat_ids_env", "NEW_CHATS"),
569            ],
570        )
571        .unwrap();
572        save(&doc, &path).unwrap();
573
574        let after = fs::read_to_string(&path).unwrap();
575        // The replace itself worked.
576        assert!(
577            after.contains("        bot_token_env: NEW_TOKEN"),
578            "leaf not replaced:\n{after}"
579        );
580        // The file-final trailing comment survives the replace (#319).
581        assert!(
582            after.contains("# operator note: keep this trailing footer"),
583            "file-final trailing comment eaten on leaf replace (#319):\n{after}"
584        );
585        // The blank line separating the leaf from the footer survives too.
586        assert!(
587            after.contains("\n\n# operator note: keep this trailing footer"),
588            "file-final trailing blank line eaten on leaf replace (#319):\n{after}"
589        );
590    }
591
592    /// #319 edge: more than one trailing comment, interleaved with blank
593    /// lines, all following a file-final leaf. `block_end_after` skips every
594    /// comment/blank line without advancing `last_content`, so the splice
595    /// range stops at the leaf's last value line and the whole trivia block
596    /// survives verbatim and in order.
597    #[test]
598    fn replace_file_final_leaf_preserves_multiple_trailing_comments() {
599        let dir = tempfile::tempdir().unwrap();
600        let path = dir.path().join("multi-footer.yaml");
601        let fixture = "\
602version: 2
603managers:
604  pm:
605    runtime: claude-code
606    interfaces:
607      telegram:
608        bot_token_env: OLD_TOKEN
609        chat_ids_env: OLD_CHATS
610
611# footer line one
612
613# footer line two
614# footer line three
615";
616        fs::write(&path, fixture).unwrap();
617
618        let doc = load(&path).unwrap();
619        let doc = set_nested_mapping(
620            doc,
621            &["managers", "pm", "interfaces", "telegram"],
622            &[
623                ("bot_token_env", "NEW_TOKEN"),
624                ("chat_ids_env", "NEW_CHATS"),
625            ],
626        )
627        .unwrap();
628        save(&doc, &path).unwrap();
629
630        let after = fs::read_to_string(&path).unwrap();
631        assert!(
632            after.contains("        bot_token_env: NEW_TOKEN"),
633            "leaf not replaced:\n{after}"
634        );
635        // Every trailing comment survives.
636        for footer in [
637            "# footer line one",
638            "# footer line two",
639            "# footer line three",
640        ] {
641            assert!(
642                after.contains(footer),
643                "trailing comment `{footer}` eaten on leaf replace (#319):\n{after}"
644            );
645        }
646        // The trailing trivia survives verbatim — same comments, same
647        // interleaved blanks, same order, right after the replaced leaf.
648        assert!(
649            after.contains(
650                "        chat_ids_env: NEW_CHATS\n\n# footer line one\n\n# footer line two\n# footer line three\n"
651            ),
652            "trailing comment/blank cluster not preserved verbatim and in order:\n{after}"
653        );
654    }
655
656    /// #319 edge: a comment that immediately abuts the file-final leaf with
657    /// no blank line between the leaf's last value and the comment. The
658    /// comment is still trailing trivia (its indent doesn't matter — the
659    /// `#` guard short-circuits before the indent check) and must survive.
660    #[test]
661    fn replace_file_final_leaf_preserves_abutting_comment() {
662        let dir = tempfile::tempdir().unwrap();
663        let path = dir.path().join("abutting.yaml");
664        let fixture = "\
665version: 2
666managers:
667  pm:
668    runtime: claude-code
669    interfaces:
670      telegram:
671        bot_token_env: OLD_TOKEN
672        chat_ids_env: OLD_CHATS
673# abutting footer, no blank above
674";
675        fs::write(&path, fixture).unwrap();
676
677        let doc = load(&path).unwrap();
678        let doc = set_nested_mapping(
679            doc,
680            &["managers", "pm", "interfaces", "telegram"],
681            &[
682                ("bot_token_env", "NEW_TOKEN"),
683                ("chat_ids_env", "NEW_CHATS"),
684            ],
685        )
686        .unwrap();
687        save(&doc, &path).unwrap();
688
689        let after = fs::read_to_string(&path).unwrap();
690        assert!(
691            after.contains("        bot_token_env: NEW_TOKEN"),
692            "leaf not replaced:\n{after}"
693        );
694        // The comment sits directly after the leaf's last value, with no
695        // blank line inserted or eaten.
696        assert!(
697            after.contains("        chat_ids_env: NEW_CHATS\n# abutting footer, no blank above"),
698            "abutting trailing comment eaten or shifted on leaf replace (#319):\n{after}"
699        );
700    }
701
702    /// #319 edge: a trailing comment indented *deeper* than the file-final
703    /// leaf. `block_end_after`'s `trimmed.starts_with('#')` guard fires
704    /// before the `indent <= key_indent` check, so a deeper-indented comment
705    /// is treated as trivia (not block content) and `last_content` is not
706    /// advanced past it — the comment must survive uncorrupted rather than
707    /// be absorbed into the replaced range.
708    #[test]
709    fn replace_file_final_leaf_preserves_deeper_indented_comment() {
710        let dir = tempfile::tempdir().unwrap();
711        let path = dir.path().join("deep-comment.yaml");
712        let fixture = "\
713version: 2
714managers:
715  pm:
716    runtime: claude-code
717    interfaces:
718      telegram:
719        bot_token_env: OLD_TOKEN
720        chat_ids_env: OLD_CHATS
721            # deeply indented operator note
722";
723        fs::write(&path, fixture).unwrap();
724
725        let doc = load(&path).unwrap();
726        let doc = set_nested_mapping(
727            doc,
728            &["managers", "pm", "interfaces", "telegram"],
729            &[
730                ("bot_token_env", "NEW_TOKEN"),
731                ("chat_ids_env", "NEW_CHATS"),
732            ],
733        )
734        .unwrap();
735        save(&doc, &path).unwrap();
736
737        let after = fs::read_to_string(&path).unwrap();
738        assert!(
739            after.contains("        bot_token_env: NEW_TOKEN"),
740            "leaf not replaced:\n{after}"
741        );
742        // The deeper-indented comment survives with its indentation intact.
743        assert!(
744            after.contains("            # deeply indented operator note"),
745            "deeper-indented trailing comment corrupted on leaf replace (#319):\n{after}"
746        );
747    }
748
749    /// #319 edge: file-final leaf followed by a trailing comment, with the
750    /// file NOT ending in a newline. The comment survival is the #319
751    /// behavior (the file-final trailing comment is excluded from the splice
752    /// range by `block_end_after`); the no-trailing-newline preservation is
753    /// guarded by the orthogonal, pre-existing `source.ends_with('\n')` join
754    /// branch, not by the `block_end_after` change — both must hold here.
755    #[test]
756    fn replace_file_final_leaf_preserves_no_trailing_newline() {
757        let dir = tempfile::tempdir().unwrap();
758        let path = dir.path().join("no-newline.yaml");
759        // Note: fixture deliberately has no trailing newline.
760        let fixture = "\
761version: 2
762managers:
763  pm:
764    runtime: claude-code
765    interfaces:
766      telegram:
767        bot_token_env: OLD_TOKEN
768        chat_ids_env: OLD_CHATS
769
770# footer with no final newline";
771        assert!(
772            !fixture.ends_with('\n'),
773            "fixture precondition: must not end in a newline"
774        );
775        fs::write(&path, fixture).unwrap();
776
777        let doc = load(&path).unwrap();
778        let doc = set_nested_mapping(
779            doc,
780            &["managers", "pm", "interfaces", "telegram"],
781            &[
782                ("bot_token_env", "NEW_TOKEN"),
783                ("chat_ids_env", "NEW_CHATS"),
784            ],
785        )
786        .unwrap();
787        save(&doc, &path).unwrap();
788
789        let after = fs::read_to_string(&path).unwrap();
790        assert!(
791            after.contains("        bot_token_env: NEW_TOKEN"),
792            "leaf not replaced:\n{after}"
793        );
794        assert!(
795            after.contains("# footer with no final newline"),
796            "trailing comment eaten on leaf replace (#319):\n{after}"
797        );
798        // The no-trailing-newline shape is preserved.
799        assert!(
800            !after.ends_with('\n'),
801            "splice must not introduce a trailing newline the source lacked:\n{after:?}"
802        );
803    }
804
805    /// Idempotency: re-running set_nested_mapping with the same path
806    /// replaces the leaf in place rather than appending a duplicate.
807    /// Sibling adapters under the same parent survive.
808    #[test]
809    fn idempotent_replace_preserves_siblings() {
810        let dir = tempfile::tempdir().unwrap();
811        let path = dir.path().join("siblings.yaml");
812        let fixture = "\
813version: 2
814managers:
815  pm:
816    runtime: claude-code
817    interfaces:
818      discord:
819        bot_token_env: PM_DISCORD_TOKEN
820      telegram:
821        bot_token_env: OLD_TOKEN
822        chat_ids_env: OLD_CHATS
823";
824        fs::write(&path, fixture).unwrap();
825
826        let doc = load(&path).unwrap();
827        let doc = set_nested_mapping(
828            doc,
829            &["managers", "pm", "interfaces", "telegram"],
830            &[
831                ("bot_token_env", "NEW_TOKEN"),
832                ("chat_ids_env", "NEW_CHATS"),
833            ],
834        )
835        .unwrap();
836        save(&doc, &path).unwrap();
837
838        let after = fs::read_to_string(&path).unwrap();
839        assert_eq!(
840            after.matches("telegram:").count(),
841            1,
842            "duplicate telegram block:\n{after}"
843        );
844        assert_eq!(
845            after.matches("discord:").count(),
846            1,
847            "discord sibling lost:\n{after}"
848        );
849        assert!(
850            after.contains("PM_DISCORD_TOKEN"),
851            "discord adapter contents lost:\n{after}"
852        );
853        assert!(after.contains("NEW_TOKEN"));
854        assert!(after.contains("NEW_CHATS"));
855        assert!(!after.contains("OLD_TOKEN"));
856        assert!(!after.contains("OLD_CHATS"));
857    }
858
859    /// #311 root cause: replacing an **already-existing** leaf must nest
860    /// the value pairs one level *under* the re-emitted leaf key, not at
861    /// the leaf's own indent. The string-only `idempotent_replace_*`
862    /// test missed this because `contains("NEW_TOKEN")` is true even
863    /// when the pair serializes as a sibling of `telegram:` (nulling the
864    /// leaf). This asserts the parsed structure, not substrings: the
865    /// essentials scaffold pre-wires `interfaces.telegram`, so every
866    /// `bot setup` on a fresh team hit this replace path and the bridge
867    /// never saw a telegram block.
868    #[test]
869    fn replace_existing_leaf_nests_values_under_it() {
870        let dir = tempfile::tempdir().unwrap();
871        let path = dir.path().join("prewired.yaml");
872        // Mirrors the shipped `essentials` ops.yaml: a pre-wired
873        // telegram leaf the wizard re-writes verbatim.
874        let fixture = "\
875version: 2
876managers:
877  builder:
878    runtime: claude-code
879    interfaces:
880      telegram:
881        bot_token_env: TEAMCTL_TG_BUILDER_TOKEN
882        chat_ids_env: TEAMCTL_TG_BUILDER_CHATS
883";
884        fs::write(&path, fixture).unwrap();
885
886        let doc = load(&path).unwrap();
887        let doc = set_nested_mapping(
888            doc,
889            &["managers", "builder", "interfaces", "telegram"],
890            &[
891                ("bot_token_env", "TEAMCTL_TG_BUILDER_TOKEN"),
892                ("chat_ids_env", "TEAMCTL_TG_BUILDER_CHATS"),
893            ],
894        )
895        .unwrap();
896        save(&doc, &path).unwrap();
897
898        let after = fs::read_to_string(&path).unwrap();
899        let v: serde_yaml::Value = serde_yaml::from_str(&after)
900            .unwrap_or_else(|e| panic!("re-spliced YAML must parse: {e}\n{after}"));
901        let tg = &v["managers"]["builder"]["interfaces"]["telegram"];
902        assert!(
903            tg.is_mapping(),
904            "telegram leaf must remain a mapping, not be nulled by sibling pairs:\n{after}"
905        );
906        assert_eq!(
907            tg["bot_token_env"].as_str(),
908            Some("TEAMCTL_TG_BUILDER_TOKEN"),
909            "bot_token_env must be nested under telegram:\n{after}"
910        );
911        assert_eq!(
912            tg["chat_ids_env"].as_str(),
913            Some("TEAMCTL_TG_BUILDER_CHATS"),
914            "chat_ids_env must be nested under telegram:\n{after}"
915        );
916    }
917
918    // T-265 PR-a: set_top_level_scalar sibling helper.
919
920    #[test]
921    fn set_top_level_scalar_replaces_integer_with_quoted_string() {
922        // The headline use case: legacy `version: 2` integer →
923        // canonical `version: "2.0.0"` semver string.
924        let src = "\
925# leading comment
926version: 2
927broker:
928  type: sqlite
929";
930        let edited = set_top_level_scalar(src, "version", "\"2.0.0\"").unwrap();
931        let out = edited;
932        assert!(
933            out.contains("version: \"2.0.0\""),
934            "rewrite missing:\n{out}"
935        );
936        assert!(
937            !out.contains("\nversion: 2\n"),
938            "old literal survived:\n{out}"
939        );
940        // Comment + other top-level keys preserved.
941        assert!(out.contains("# leading comment"));
942        assert!(out.contains("broker:"));
943        assert!(out.contains("type: sqlite"));
944    }
945
946    #[test]
947    fn set_top_level_scalar_is_idempotent() {
948        let src = "version: \"2.0.0\"\nbroker:\n  type: sqlite\n";
949        let edited = set_top_level_scalar(src, "version", "\"2.0.0\"").unwrap();
950        let out = edited;
951        assert_eq!(
952            out.matches("version:").count(),
953            1,
954            "no duplicate version line:\n{out}"
955        );
956        assert!(out.contains("version: \"2.0.0\""));
957    }
958
959    #[test]
960    fn set_top_level_scalar_errors_on_missing_key() {
961        let src = "broker:\n  type: sqlite\n";
962        let err = set_top_level_scalar(src, "version", "\"2.0.0\"").expect_err("missing key");
963        assert!(
964            err.to_string().contains("no top-level key `version` found"),
965            "error must name the missing key: {err}"
966        );
967    }
968
969    #[test]
970    fn set_top_level_scalar_only_touches_top_level_key() {
971        // A nested `version:` inside another mapping must NOT be
972        // rewritten — only the top-level one. Crucial for
973        // projects/*.yaml which is OUT of PR-a's scope (still has
974        // `version: u32`) but might be referenced inside a future
975        // nested block.
976        let src = "\
977version: 2
978nested:
979  version: 99
980  other: ok
981";
982        let edited = set_top_level_scalar(src, "version", "\"2.0.0\"").unwrap();
983        let out = edited;
984        assert!(
985            out.contains("version: \"2.0.0\""),
986            "top-level rewritten:\n{out}"
987        );
988        assert!(
989            out.contains("  version: 99"),
990            "nested version: 99 must be left alone:\n{out}"
991        );
992    }
993}