team-core 0.9.1

Shared library for teamctl: YAML schema, validation, and artifact rendering.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
//! Comment-preserving YAML edit substrate.
//!
//! Wraps [`yaml_edit`] (rowan-backed lossless syntax tree) so callers that
//! mutate `.team/*.yaml` keep the user's comments, blank-line clusters, and
//! key ordering intact across save. The previous `serde_yaml::Value`
//! round-trip stripped all of that on every write — see the dogfood
//! `.team/projects/teamctl.yaml` regressions on the PR #54 + PR #55
//! cascades for the class this closes.
//!
//! ## Surface
//!
//! - [`load`] / [`save`] — IO with `anyhow` context.
//! - Re-exports of [`yaml_edit::Document`], [`yaml_edit::Mapping`],
//!   [`yaml_edit::Sequence`], and [`YamlPath`] so callers can drive the
//!   editor directly for round-trip + leaf updates.
//! - [`set_nested_mapping`] — bounded line-anchored helper for the one
//!   pattern yaml-edit 0.2.x can't do natively: insert or replace a
//!   properly-indented sub-block at a known parent path.
//!
//! ## Why the bounded helper
//!
//! `yaml_edit::Document::set_path` creates intermediate mappings via
//! `MappingBuilder::new().build_document().as_mapping()` and inserts them
//! with `mapping.set(key, &empty_mapping)`. The empty mapping has zero
//! base-indent, and the resulting nested entries serialize at column 0
//! instead of indenting under the parent (see `path::set_path_on_mapping`,
//! registry source line 401-435 of yaml-edit 0.2.1). Filed upstream for
//! a fix; until then [`set_nested_mapping`] handles the create-nested
//! pattern via line-anchored splice into the source string before the
//! Document re-parse. Substrate consumers never see the splice.
//!
//! Per-pm scope lock (msg 1969): the helper handles ONE pattern only
//! ("insert a properly-indented sub-block at a known parent path"). If a
//! future T-077-E verb needs a different yaml-edit-gap workaround,
//! escalate; do NOT generalize this helper.

use std::fs;
use std::path::Path;

use anyhow::{anyhow, Context, Result};

pub use yaml_edit::path::YamlPath;
pub use yaml_edit::{Document, Mapping, Sequence};

/// Read `path` and parse it as an editable YAML document.
///
/// The returned [`Document`] retains the source's comments, blank-line
/// clusters, and key ordering; mutations applied to it preserve everything
/// outside the touched range.
pub fn load(path: &Path) -> Result<Document> {
    let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
    raw.parse::<Document>()
        .with_context(|| format!("parse {}", path.display()))
}

/// Serialize `doc` and write it to `path`, replacing any previous contents.
///
/// `Document`'s `Display` impl emits the underlying syntax tree verbatim,
/// so untouched regions round-trip byte-for-byte (modulo the upstream
/// pre-document-trivia limitation noted in the module docs).
pub fn save(doc: &Document, path: &Path) -> Result<()> {
    fs::write(path, doc.to_string()).with_context(|| format!("write {}", path.display()))?;
    Ok(())
}

/// Replace a top-level scalar value (T-265 PR-a).
///
/// Walks `source` line-by-line to find a top-level (indent = 0)
/// mapping entry whose key is `key`, then rewrites that single line
/// to `key: value` while leaving every other byte of the document
/// (comments, blank lines, key order, every nested block) untouched.
///
/// **This is a sibling helper to [`set_nested_mapping`], not a
/// generalization of it** — the per-pm scope-lock at the top of
/// this module covers the nested-mapping insert gap; the top-level
/// scalar gap is structurally distinct (no parent path, no
/// indent-arithmetic, no leaf-block detection) and warrants its
/// own focused helper rather than bolting onto the nested one.
/// Used by [`crate::compose::Compose::load`] to auto-rewrite the
/// legacy `version: 2` integer literal to the semver string
/// `"2.0.0"` per owner ratification (tg 2989 + 3440).
///
/// # Errors
/// Returns an error if no top-level mapping entry named `key` is
/// found in the document. Document re-parse failures (which
/// shouldn't happen for the bounded edit this performs) are
/// surfaced as errors.
pub fn set_top_level_scalar(source: &str, key: &str, value: &str) -> Result<String> {
    let trailing_newline = source.ends_with('\n');
    let mut out_lines: Vec<String> = Vec::new();
    let mut rewrote = false;
    for line in source.lines() {
        if !rewrote {
            // Top-level means indent = 0 (no leading whitespace).
            // Skip blank lines and comment lines (full-line `#`).
            let trimmed = line.trim_start();
            let indent = line.len() - trimmed.len();
            if indent == 0 && !trimmed.is_empty() && !trimmed.starts_with('#') {
                if let Some((found_key, _rest)) = trimmed.split_once(':') {
                    if found_key == key {
                        out_lines.push(format!("{key}: {value}"));
                        rewrote = true;
                        continue;
                    }
                }
            }
        }
        out_lines.push(line.to_string());
    }
    if !rewrote {
        return Err(anyhow!(
            "set_top_level_scalar: no top-level key `{key}` found in document"
        ));
    }
    let mut joined = out_lines.join("\n");
    if trailing_newline && !joined.ends_with('\n') {
        joined.push('\n');
    }
    // T-265 PR-a: deliberately return the spliced String directly
    // rather than round-tripping through `Document::parse` →
    // `Document::to_string` — the yaml_edit 0.2.x upstream has a
    // pre-document-trivia limitation that strips leading comments
    // on round-trip, and the splice already preserves them
    // line-perfectly. The caller writes this straight to disk.
    Ok(joined)
}

/// Insert or replace a nested mapping at the given parent path.
///
/// `parent_path` is a sequence of mapping keys descending from the root.
/// All but the last key must resolve to a mapping that the helper can
/// either find in the source or create alongside its existing siblings.
/// The last key (the leaf) is the mapping the caller wants to upsert.
/// `value_pairs` becomes the body of that leaf mapping.
///
/// Existing siblings of the leaf — and existing siblings of any
/// intermediate the helper has to create — are preserved with their
/// comments and ordering intact. If the leaf mapping already exists,
/// it is replaced wholesale by `value_pairs`. Other adapters under the
/// same parent (e.g. `discord:` next to `telegram:`) survive.
///
/// # Errors
/// Returns an error if `parent_path` is empty or if the first key is
/// not a top-level mapping in the document.
pub fn set_nested_mapping(
    doc: Document,
    parent_path: &[&str],
    value_pairs: &[(&str, &str)],
) -> Result<Document> {
    if parent_path.is_empty() {
        return Err(anyhow!("set_nested_mapping: parent_path must not be empty"));
    }
    let source = doc.to_string();
    let edited = splice_nested_mapping(&source, parent_path, value_pairs)?;
    edited
        .parse::<Document>()
        .with_context(|| "re-parse spliced YAML")
}

/// Line-anchored splice: walk `source` to find the deepest existing
/// ancestor of `path`, then insert (or replace) the missing tail and the
/// leaf body at the right indent.
fn splice_nested_mapping(
    source: &str,
    path: &[&str],
    value_pairs: &[(&str, &str)],
) -> Result<String> {
    let lines: Vec<&str> = source.lines().collect();
    let trailing_newline = source.ends_with('\n');

    // Walk the path top-down, tracking the (line, indent) of each existing
    // ancestor. Stop at the first missing component.
    let mut current_indent: usize = 0;
    let mut search_start: usize = 0;
    let mut search_end: usize = lines.len();
    let mut existing_depth: usize = 0;
    let mut leaf_replace_range: Option<(usize, usize, usize)> = None; // (start_line, end_line_exclusive, leaf_indent)

    for (depth, key) in path.iter().enumerate() {
        let parent_indent = current_indent;
        let child_indent_min = if depth == 0 { 0 } else { parent_indent + 1 };
        match find_key_in_block(&lines, search_start, search_end, key, child_indent_min) {
            Some((line_idx, key_indent)) => {
                existing_depth = depth + 1;
                current_indent = key_indent;
                let block_end = block_end_after(&lines, line_idx, key_indent);
                if depth == path.len() - 1 {
                    leaf_replace_range = Some((line_idx, block_end, key_indent));
                } else {
                    search_start = line_idx + 1;
                    search_end = block_end;
                }
            }
            None => break,
        }
    }

    if existing_depth == 0 {
        return Err(anyhow!(
            "set_nested_mapping: top-level key `{}` not found",
            path[0]
        ));
    }

    // Build the replacement / insertion block.
    let insert_indent = if existing_depth == path.len() {
        // Leaf already exists; reuse its indent.
        leaf_replace_range.expect("leaf existed").2
    } else {
        // First missing component lands one level deeper than its parent.
        current_indent + 2
    };

    let missing_tail = &path[existing_depth..];
    let mut block_lines: Vec<String> = Vec::new();
    let mut indent = insert_indent;
    for key in missing_tail {
        block_lines.push(format!("{:indent$}{key}:", "", indent = indent, key = key));
        indent += 2;
    }
    // Leaf-relative value indent. When the leaf was missing, the
    // `missing_tail` loop already emitted the leaf key and advanced
    // `indent` one level past it, so `indent` is the correct child
    // level. When the leaf already existed, `missing_tail` was empty —
    // `indent` still equals the leaf's own indent, so values must sit
    // one level deeper than the re-emitted leaf key. Without this they
    // serialize as siblings of the leaf, silently nulling it (#311:
    // `bot setup` re-running against an essentials team — whose
    // scaffold pre-wires `interfaces.telegram` — produced `telegram:`
    // with sibling `bot_token_env:`, so `agent.telegram()` parsed
    // `None` and the Telegram bridge never started).
    let value_indent = if existing_depth == path.len() {
        insert_indent + 2
    } else {
        indent
    };
    if existing_depth == path.len() {
        // We're replacing an existing leaf — emit the leaf key line too.
        block_lines.push(format!(
            "{:indent$}{key}:",
            "",
            indent = insert_indent,
            key = path[path.len() - 1]
        ));
    }
    for (k, v) in value_pairs {
        block_lines.push(format!(
            "{:indent$}{k}: {v}",
            "",
            indent = value_indent,
            k = k,
            v = v
        ));
    }

    // Splice: replace the leaf-block range or insert at the parent's
    // block end.
    let mut out_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
    if let Some((start, end, _)) = leaf_replace_range {
        out_lines.splice(start..end, block_lines);
    } else {
        // Insert at the end of the deepest-existing-ancestor's block.
        // search_end is that block's end (exclusive); insert there.
        out_lines.splice(search_end..search_end, block_lines);
    }

    let mut joined = out_lines.join("\n");
    if trailing_newline && !joined.ends_with('\n') {
        joined.push('\n');
    }
    Ok(joined)
}

/// Within `lines[start..end]`, find a mapping key whose indent is `>=
/// min_indent` AND is the direct-child indent of its parent (i.e. the
/// minimum indent appearing in this slice that is `>= min_indent`).
/// Returns `(line_idx, indent)`.
fn find_key_in_block(
    lines: &[&str],
    start: usize,
    end: usize,
    key: &str,
    min_indent: usize,
) -> Option<(usize, usize)> {
    // First pass: find the smallest indent in this slice that's >= min_indent
    // and belongs to a mapping key line (`<indent>foo:` with foo non-empty).
    // That defines "direct children" of the parent.
    let mut child_indent: Option<usize> = None;
    for line in lines.iter().take(end).skip(start) {
        if let Some((indent, _)) = parse_mapping_key_line(line) {
            if indent >= min_indent {
                child_indent = Some(child_indent.map_or(indent, |c| c.min(indent)));
            }
        }
    }
    let child_indent = child_indent?;

    // Second pass: find the named key at exactly child_indent.
    for (i, line) in lines.iter().enumerate().take(end).skip(start) {
        if let Some((indent, found_key)) = parse_mapping_key_line(line) {
            if indent == child_indent && found_key == key {
                return Some((i, indent));
            }
        }
    }
    None
}

/// Returns `(indent, key)` if `line` is a `<indent>key:` mapping entry —
/// i.e. starts with spaces, has a non-empty unquoted-non-list key, and
/// ends `:` (possibly followed by whitespace + an inline value).
///
/// Conservative: this helper does NOT recognise quoted keys, flow
/// mappings, or anchors. The verbs T-077-E targets stick to the canonical
/// block-style YAML in `examples/*/.team/`, which uses none of those.
/// If a future verb needs broader coverage, escalate per the pm-locked
/// scope rule in the module docs — do not silently extend here.
fn parse_mapping_key_line(line: &str) -> Option<(usize, &str)> {
    let indent = line.len() - line.trim_start().len();
    let trimmed = line.trim_start();
    if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
        return None;
    }
    let colon_idx = trimmed.find(':')?;
    let key = &trimmed[..colon_idx];
    if key.is_empty() {
        return None;
    }
    // Reject lines like "key: value: tail" — only recognise where the key
    // contains no colon. This keeps us out of inline-value territory.
    if key.contains(':') {
        return None;
    }
    // After ':' must be end-of-line OR whitespace (then either end-of-line
    // for a parent mapping, or value).
    let after = &trimmed[colon_idx + 1..];
    if !after.is_empty() && !after.starts_with(char::is_whitespace) {
        // e.g. `http://...` — colon is part of a value, not a key separator.
        return None;
    }
    Some((indent, key))
}

/// End (exclusive) of the block belonging to a key at line `key_line` with
/// indent `key_indent`. The block includes every following line whose
/// effective indent is `> key_indent` plus interleaved blank/comment
/// lines, stopping at the first line with indent `<= key_indent` that is
/// itself a mapping key (or end of file).
fn block_end_after(lines: &[&str], key_line: usize, key_indent: usize) -> usize {
    for (i, line) in lines.iter().enumerate().skip(key_line + 1) {
        let trimmed = line.trim_start();
        if trimmed.is_empty() || trimmed.starts_with('#') {
            continue;
        }
        let indent = line.len() - trimmed.len();
        if indent <= key_indent {
            return i;
        }
    }
    lines.len()
}

#[cfg(test)]
mod tests {
    use super::*;

    const COMMENTED_FIXTURE: &str = "\
version: 2

# managers block: each manager is a long-running agent.
managers:
  pm:
    runtime: claude-code  # canonical runtime
    role_prompt: roles/pm.md
    # interfaces lands here once `teamctl bot setup` runs
  eng_lead:
    runtime: claude-code
    role_prompt: roles/eng_lead.md

# trailing footer
";

    #[test]
    fn round_trip_preserves_byte_for_byte() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("fixture.yaml");
        fs::write(&path, COMMENTED_FIXTURE).unwrap();

        let doc = load(&path).unwrap();
        save(&doc, &path).unwrap();

        let after = fs::read_to_string(&path).unwrap();
        assert_eq!(
            after, COMMENTED_FIXTURE,
            "load → save without mutation must be byte-perfect"
        );
    }

    #[test]
    fn mutation_preserves_comments() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("fixture.yaml");
        fs::write(&path, COMMENTED_FIXTURE).unwrap();

        let doc = load(&path).unwrap();
        let doc = set_nested_mapping(
            doc,
            &["managers", "pm", "interfaces", "telegram"],
            &[("bot_token_env", "PM_TOKEN"), ("chat_ids_env", "PM_CHATS")],
        )
        .unwrap();
        save(&doc, &path).unwrap();

        let after = fs::read_to_string(&path).unwrap();

        assert!(
            after.contains("# managers block: each manager is a long-running agent."),
            "block comment dropped:\n{after}"
        );
        assert!(
            after.contains("# canonical runtime"),
            "trailing line comment dropped:\n{after}"
        );
        assert!(
            after.contains("# trailing footer"),
            "footer comment dropped:\n{after}"
        );
        assert!(
            after.contains("    interfaces:"),
            "interfaces not properly indented under pm:\n{after}"
        );
        assert!(
            after.contains("      telegram:"),
            "telegram not properly indented under interfaces:\n{after}"
        );
        assert!(
            after.contains("        bot_token_env: PM_TOKEN"),
            "leaf not properly indented:\n{after}"
        );
        assert!(after.contains("        chat_ids_env: PM_CHATS"));

        // Key ordering preserved on unchanged sections.
        let pm_idx = after.find("pm:").expect("pm key");
        let eng_idx = after.find("eng_lead:").expect("eng_lead key");
        assert!(pm_idx < eng_idx, "manager key order swapped:\n{after}");

        // Blank line separator between pm and eng_lead survives.
        assert!(
            after.contains("\n  eng_lead:"),
            "eng_lead boundary broken:\n{after}"
        );
    }

    /// Regression test for the dogfood-yaml class that hit PR #54 + PR #55.
    /// Saving through this substrate doesn't strip the comments the user
    /// put in their project YAML.
    #[test]
    fn save_does_not_strip_existing_comments() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("oss-shape.yaml");
        let fixture = "\
version: 2

project:
  id: oss
  name: OSS Maintainer
  cwd: ./workspace

# Hub-and-spoke: maintainer is the only manager; workers fan out below.
managers:
  maintainer:
    runtime: claude-code
    role_prompt: roles/maintainer.md
    # `teamctl bot setup` writes the interfaces.telegram block here.

workers:
  bug_fix:
    runtime: claude-code  # workers default to sonnet
    reports_to: maintainer
";
        fs::write(&path, fixture).unwrap();

        let doc = load(&path).unwrap();
        let doc = set_nested_mapping(
            doc,
            &["managers", "maintainer", "interfaces", "telegram"],
            &[
                ("bot_token_env", "TEAMCTL_TG_MAINTAINER_TOKEN"),
                ("chat_ids_env", "TEAMCTL_TG_MAINTAINER_CHATS"),
            ],
        )
        .unwrap();
        save(&doc, &path).unwrap();

        let after = fs::read_to_string(&path).unwrap();
        assert!(
            after.contains(
                "# Hub-and-spoke: maintainer is the only manager; workers fan out below."
            ),
            "block comment dropped — regression class still open:\n{after}"
        );
        assert!(
            after.contains("# `teamctl bot setup` writes the interfaces.telegram block here."),
            "inline comment dropped:\n{after}"
        );
        assert!(
            after.contains("# workers default to sonnet"),
            "trailing line comment dropped:\n{after}"
        );
        assert!(after.contains("    interfaces:"));
        assert!(after.contains("      telegram:"));
        assert!(after.contains("        bot_token_env: TEAMCTL_TG_MAINTAINER_TOKEN"));
        assert!(after.contains("        chat_ids_env: TEAMCTL_TG_MAINTAINER_CHATS"));
    }

    /// Idempotency: re-running set_nested_mapping with the same path
    /// replaces the leaf in place rather than appending a duplicate.
    /// Sibling adapters under the same parent survive.
    #[test]
    fn idempotent_replace_preserves_siblings() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("siblings.yaml");
        let fixture = "\
version: 2
managers:
  pm:
    runtime: claude-code
    interfaces:
      discord:
        bot_token_env: PM_DISCORD_TOKEN
      telegram:
        bot_token_env: OLD_TOKEN
        chat_ids_env: OLD_CHATS
";
        fs::write(&path, fixture).unwrap();

        let doc = load(&path).unwrap();
        let doc = set_nested_mapping(
            doc,
            &["managers", "pm", "interfaces", "telegram"],
            &[
                ("bot_token_env", "NEW_TOKEN"),
                ("chat_ids_env", "NEW_CHATS"),
            ],
        )
        .unwrap();
        save(&doc, &path).unwrap();

        let after = fs::read_to_string(&path).unwrap();
        assert_eq!(
            after.matches("telegram:").count(),
            1,
            "duplicate telegram block:\n{after}"
        );
        assert_eq!(
            after.matches("discord:").count(),
            1,
            "discord sibling lost:\n{after}"
        );
        assert!(
            after.contains("PM_DISCORD_TOKEN"),
            "discord adapter contents lost:\n{after}"
        );
        assert!(after.contains("NEW_TOKEN"));
        assert!(after.contains("NEW_CHATS"));
        assert!(!after.contains("OLD_TOKEN"));
        assert!(!after.contains("OLD_CHATS"));
    }

    /// #311 root cause: replacing an **already-existing** leaf must nest
    /// the value pairs one level *under* the re-emitted leaf key, not at
    /// the leaf's own indent. The string-only `idempotent_replace_*`
    /// test missed this because `contains("NEW_TOKEN")` is true even
    /// when the pair serializes as a sibling of `telegram:` (nulling the
    /// leaf). This asserts the parsed structure, not substrings: the
    /// essentials scaffold pre-wires `interfaces.telegram`, so every
    /// `bot setup` on a fresh team hit this replace path and the bridge
    /// never saw a telegram block.
    #[test]
    fn replace_existing_leaf_nests_values_under_it() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("prewired.yaml");
        // Mirrors the shipped `essentials` ops.yaml: a pre-wired
        // telegram leaf the wizard re-writes verbatim.
        let fixture = "\
version: 2
managers:
  builder:
    runtime: claude-code
    interfaces:
      telegram:
        bot_token_env: TEAMCTL_TG_BUILDER_TOKEN
        chat_ids_env: TEAMCTL_TG_BUILDER_CHATS
";
        fs::write(&path, fixture).unwrap();

        let doc = load(&path).unwrap();
        let doc = set_nested_mapping(
            doc,
            &["managers", "builder", "interfaces", "telegram"],
            &[
                ("bot_token_env", "TEAMCTL_TG_BUILDER_TOKEN"),
                ("chat_ids_env", "TEAMCTL_TG_BUILDER_CHATS"),
            ],
        )
        .unwrap();
        save(&doc, &path).unwrap();

        let after = fs::read_to_string(&path).unwrap();
        let v: serde_yaml::Value = serde_yaml::from_str(&after)
            .unwrap_or_else(|e| panic!("re-spliced YAML must parse: {e}\n{after}"));
        let tg = &v["managers"]["builder"]["interfaces"]["telegram"];
        assert!(
            tg.is_mapping(),
            "telegram leaf must remain a mapping, not be nulled by sibling pairs:\n{after}"
        );
        assert_eq!(
            tg["bot_token_env"].as_str(),
            Some("TEAMCTL_TG_BUILDER_TOKEN"),
            "bot_token_env must be nested under telegram:\n{after}"
        );
        assert_eq!(
            tg["chat_ids_env"].as_str(),
            Some("TEAMCTL_TG_BUILDER_CHATS"),
            "chat_ids_env must be nested under telegram:\n{after}"
        );
    }

    // T-265 PR-a: set_top_level_scalar sibling helper.

    #[test]
    fn set_top_level_scalar_replaces_integer_with_quoted_string() {
        // The headline use case: legacy `version: 2` integer →
        // canonical `version: "2.0.0"` semver string.
        let src = "\
# leading comment
version: 2
broker:
  type: sqlite
";
        let edited = set_top_level_scalar(src, "version", "\"2.0.0\"").unwrap();
        let out = edited;
        assert!(
            out.contains("version: \"2.0.0\""),
            "rewrite missing:\n{out}"
        );
        assert!(
            !out.contains("\nversion: 2\n"),
            "old literal survived:\n{out}"
        );
        // Comment + other top-level keys preserved.
        assert!(out.contains("# leading comment"));
        assert!(out.contains("broker:"));
        assert!(out.contains("type: sqlite"));
    }

    #[test]
    fn set_top_level_scalar_is_idempotent() {
        let src = "version: \"2.0.0\"\nbroker:\n  type: sqlite\n";
        let edited = set_top_level_scalar(src, "version", "\"2.0.0\"").unwrap();
        let out = edited;
        assert_eq!(
            out.matches("version:").count(),
            1,
            "no duplicate version line:\n{out}"
        );
        assert!(out.contains("version: \"2.0.0\""));
    }

    #[test]
    fn set_top_level_scalar_errors_on_missing_key() {
        let src = "broker:\n  type: sqlite\n";
        let err = set_top_level_scalar(src, "version", "\"2.0.0\"").expect_err("missing key");
        assert!(
            err.to_string().contains("no top-level key `version` found"),
            "error must name the missing key: {err}"
        );
    }

    #[test]
    fn set_top_level_scalar_only_touches_top_level_key() {
        // A nested `version:` inside another mapping must NOT be
        // rewritten — only the top-level one. Crucial for
        // projects/*.yaml which is OUT of PR-a's scope (still has
        // `version: u32`) but might be referenced inside a future
        // nested block.
        let src = "\
version: 2
nested:
  version: 99
  other: ok
";
        let edited = set_top_level_scalar(src, "version", "\"2.0.0\"").unwrap();
        let out = edited;
        assert!(
            out.contains("version: \"2.0.0\""),
            "top-level rewritten:\n{out}"
        );
        assert!(
            out.contains("  version: 99"),
            "nested version: 99 must be left alone:\n{out}"
        );
    }
}