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/// Insert or replace a nested mapping at the given parent path.
67///
68/// `parent_path` is a sequence of mapping keys descending from the root.
69/// All but the last key must resolve to a mapping that the helper can
70/// either find in the source or create alongside its existing siblings.
71/// The last key (the leaf) is the mapping the caller wants to upsert.
72/// `value_pairs` becomes the body of that leaf mapping.
73///
74/// Existing siblings of the leaf — and existing siblings of any
75/// intermediate the helper has to create — are preserved with their
76/// comments and ordering intact. If the leaf mapping already exists,
77/// it is replaced wholesale by `value_pairs`. Other adapters under the
78/// same parent (e.g. `discord:` next to `telegram:`) survive.
79///
80/// # Errors
81/// Returns an error if `parent_path` is empty or if the first key is
82/// not a top-level mapping in the document.
83pub fn set_nested_mapping(
84    doc: Document,
85    parent_path: &[&str],
86    value_pairs: &[(&str, &str)],
87) -> Result<Document> {
88    if parent_path.is_empty() {
89        return Err(anyhow!("set_nested_mapping: parent_path must not be empty"));
90    }
91    let source = doc.to_string();
92    let edited = splice_nested_mapping(&source, parent_path, value_pairs)?;
93    edited
94        .parse::<Document>()
95        .with_context(|| "re-parse spliced YAML")
96}
97
98/// Line-anchored splice: walk `source` to find the deepest existing
99/// ancestor of `path`, then insert (or replace) the missing tail and the
100/// leaf body at the right indent.
101fn splice_nested_mapping(
102    source: &str,
103    path: &[&str],
104    value_pairs: &[(&str, &str)],
105) -> Result<String> {
106    let lines: Vec<&str> = source.lines().collect();
107    let trailing_newline = source.ends_with('\n');
108
109    // Walk the path top-down, tracking the (line, indent) of each existing
110    // ancestor. Stop at the first missing component.
111    let mut current_indent: usize = 0;
112    let mut search_start: usize = 0;
113    let mut search_end: usize = lines.len();
114    let mut existing_depth: usize = 0;
115    let mut leaf_replace_range: Option<(usize, usize, usize)> = None; // (start_line, end_line_exclusive, leaf_indent)
116
117    for (depth, key) in path.iter().enumerate() {
118        let parent_indent = current_indent;
119        let child_indent_min = if depth == 0 { 0 } else { parent_indent + 1 };
120        match find_key_in_block(&lines, search_start, search_end, key, child_indent_min) {
121            Some((line_idx, key_indent)) => {
122                existing_depth = depth + 1;
123                current_indent = key_indent;
124                let block_end = block_end_after(&lines, line_idx, key_indent);
125                if depth == path.len() - 1 {
126                    leaf_replace_range = Some((line_idx, block_end, key_indent));
127                } else {
128                    search_start = line_idx + 1;
129                    search_end = block_end;
130                }
131            }
132            None => break,
133        }
134    }
135
136    if existing_depth == 0 {
137        return Err(anyhow!(
138            "set_nested_mapping: top-level key `{}` not found",
139            path[0]
140        ));
141    }
142
143    // Build the replacement / insertion block.
144    let insert_indent = if existing_depth == path.len() {
145        // Leaf already exists; reuse its indent.
146        leaf_replace_range.expect("leaf existed").2
147    } else {
148        // First missing component lands one level deeper than its parent.
149        current_indent + 2
150    };
151
152    let missing_tail = &path[existing_depth..];
153    let mut block_lines: Vec<String> = Vec::new();
154    let mut indent = insert_indent;
155    for key in missing_tail {
156        block_lines.push(format!("{:indent$}{key}:", "", indent = indent, key = key));
157        indent += 2;
158    }
159    // Leaf indent: if the leaf was found, missing_tail is empty and `indent`
160    // == insert_indent. Otherwise indent has already advanced past the last
161    // missing key. In both cases the value pairs sit at `indent`.
162    let value_indent = indent;
163    if existing_depth == path.len() {
164        // We're replacing an existing leaf — emit the leaf key line too.
165        block_lines.push(format!(
166            "{:indent$}{key}:",
167            "",
168            indent = insert_indent,
169            key = path[path.len() - 1]
170        ));
171    }
172    for (k, v) in value_pairs {
173        block_lines.push(format!(
174            "{:indent$}{k}: {v}",
175            "",
176            indent = value_indent,
177            k = k,
178            v = v
179        ));
180    }
181
182    // Splice: replace the leaf-block range or insert at the parent's
183    // block end.
184    let mut out_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
185    if let Some((start, end, _)) = leaf_replace_range {
186        out_lines.splice(start..end, block_lines);
187    } else {
188        // Insert at the end of the deepest-existing-ancestor's block.
189        // search_end is that block's end (exclusive); insert there.
190        out_lines.splice(search_end..search_end, block_lines);
191    }
192
193    let mut joined = out_lines.join("\n");
194    if trailing_newline && !joined.ends_with('\n') {
195        joined.push('\n');
196    }
197    Ok(joined)
198}
199
200/// Within `lines[start..end]`, find a mapping key whose indent is `>=
201/// min_indent` AND is the direct-child indent of its parent (i.e. the
202/// minimum indent appearing in this slice that is `>= min_indent`).
203/// Returns `(line_idx, indent)`.
204fn find_key_in_block(
205    lines: &[&str],
206    start: usize,
207    end: usize,
208    key: &str,
209    min_indent: usize,
210) -> Option<(usize, usize)> {
211    // First pass: find the smallest indent in this slice that's >= min_indent
212    // and belongs to a mapping key line (`<indent>foo:` with foo non-empty).
213    // That defines "direct children" of the parent.
214    let mut child_indent: Option<usize> = None;
215    for line in lines.iter().take(end).skip(start) {
216        if let Some((indent, _)) = parse_mapping_key_line(line) {
217            if indent >= min_indent {
218                child_indent = Some(child_indent.map_or(indent, |c| c.min(indent)));
219            }
220        }
221    }
222    let child_indent = child_indent?;
223
224    // Second pass: find the named key at exactly child_indent.
225    for (i, line) in lines.iter().enumerate().take(end).skip(start) {
226        if let Some((indent, found_key)) = parse_mapping_key_line(line) {
227            if indent == child_indent && found_key == key {
228                return Some((i, indent));
229            }
230        }
231    }
232    None
233}
234
235/// Returns `(indent, key)` if `line` is a `<indent>key:` mapping entry —
236/// i.e. starts with spaces, has a non-empty unquoted-non-list key, and
237/// ends `:` (possibly followed by whitespace + an inline value).
238///
239/// Conservative: this helper does NOT recognise quoted keys, flow
240/// mappings, or anchors. The verbs T-077-E targets stick to the canonical
241/// block-style YAML in `examples/*/.team/`, which uses none of those.
242/// If a future verb needs broader coverage, escalate per the pm-locked
243/// scope rule in the module docs — do not silently extend here.
244fn parse_mapping_key_line(line: &str) -> Option<(usize, &str)> {
245    let indent = line.len() - line.trim_start().len();
246    let trimmed = line.trim_start();
247    if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
248        return None;
249    }
250    let colon_idx = trimmed.find(':')?;
251    let key = &trimmed[..colon_idx];
252    if key.is_empty() {
253        return None;
254    }
255    // Reject lines like "key: value: tail" — only recognise where the key
256    // contains no colon. This keeps us out of inline-value territory.
257    if key.contains(':') {
258        return None;
259    }
260    // After ':' must be end-of-line OR whitespace (then either end-of-line
261    // for a parent mapping, or value).
262    let after = &trimmed[colon_idx + 1..];
263    if !after.is_empty() && !after.starts_with(char::is_whitespace) {
264        // e.g. `http://...` — colon is part of a value, not a key separator.
265        return None;
266    }
267    Some((indent, key))
268}
269
270/// End (exclusive) of the block belonging to a key at line `key_line` with
271/// indent `key_indent`. The block includes every following line whose
272/// effective indent is `> key_indent` plus interleaved blank/comment
273/// lines, stopping at the first line with indent `<= key_indent` that is
274/// itself a mapping key (or end of file).
275fn block_end_after(lines: &[&str], key_line: usize, key_indent: usize) -> usize {
276    for (i, line) in lines.iter().enumerate().skip(key_line + 1) {
277        let trimmed = line.trim_start();
278        if trimmed.is_empty() || trimmed.starts_with('#') {
279            continue;
280        }
281        let indent = line.len() - trimmed.len();
282        if indent <= key_indent {
283            return i;
284        }
285    }
286    lines.len()
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    const COMMENTED_FIXTURE: &str = "\
294version: 2
295
296# managers block: each manager is a long-running agent.
297managers:
298  pm:
299    runtime: claude-code  # canonical runtime
300    role_prompt: roles/pm.md
301    # interfaces lands here once `teamctl bot setup` runs
302  eng_lead:
303    runtime: claude-code
304    role_prompt: roles/eng_lead.md
305
306# trailing footer
307";
308
309    #[test]
310    fn round_trip_preserves_byte_for_byte() {
311        let dir = tempfile::tempdir().unwrap();
312        let path = dir.path().join("fixture.yaml");
313        fs::write(&path, COMMENTED_FIXTURE).unwrap();
314
315        let doc = load(&path).unwrap();
316        save(&doc, &path).unwrap();
317
318        let after = fs::read_to_string(&path).unwrap();
319        assert_eq!(
320            after, COMMENTED_FIXTURE,
321            "load → save without mutation must be byte-perfect"
322        );
323    }
324
325    #[test]
326    fn mutation_preserves_comments() {
327        let dir = tempfile::tempdir().unwrap();
328        let path = dir.path().join("fixture.yaml");
329        fs::write(&path, COMMENTED_FIXTURE).unwrap();
330
331        let doc = load(&path).unwrap();
332        let doc = set_nested_mapping(
333            doc,
334            &["managers", "pm", "interfaces", "telegram"],
335            &[("bot_token_env", "PM_TOKEN"), ("chat_ids_env", "PM_CHATS")],
336        )
337        .unwrap();
338        save(&doc, &path).unwrap();
339
340        let after = fs::read_to_string(&path).unwrap();
341
342        assert!(
343            after.contains("# managers block: each manager is a long-running agent."),
344            "block comment dropped:\n{after}"
345        );
346        assert!(
347            after.contains("# canonical runtime"),
348            "trailing line comment dropped:\n{after}"
349        );
350        assert!(
351            after.contains("# trailing footer"),
352            "footer comment dropped:\n{after}"
353        );
354        assert!(
355            after.contains("    interfaces:"),
356            "interfaces not properly indented under pm:\n{after}"
357        );
358        assert!(
359            after.contains("      telegram:"),
360            "telegram not properly indented under interfaces:\n{after}"
361        );
362        assert!(
363            after.contains("        bot_token_env: PM_TOKEN"),
364            "leaf not properly indented:\n{after}"
365        );
366        assert!(after.contains("        chat_ids_env: PM_CHATS"));
367
368        // Key ordering preserved on unchanged sections.
369        let pm_idx = after.find("pm:").expect("pm key");
370        let eng_idx = after.find("eng_lead:").expect("eng_lead key");
371        assert!(pm_idx < eng_idx, "manager key order swapped:\n{after}");
372
373        // Blank line separator between pm and eng_lead survives.
374        assert!(
375            after.contains("\n  eng_lead:"),
376            "eng_lead boundary broken:\n{after}"
377        );
378    }
379
380    /// Regression test for the dogfood-yaml class that hit PR #54 + PR #55.
381    /// Saving through this substrate doesn't strip the comments the user
382    /// put in their project YAML.
383    #[test]
384    fn save_does_not_strip_existing_comments() {
385        let dir = tempfile::tempdir().unwrap();
386        let path = dir.path().join("oss-shape.yaml");
387        let fixture = "\
388version: 2
389
390project:
391  id: oss
392  name: OSS Maintainer
393  cwd: ./workspace
394
395# Hub-and-spoke: maintainer is the only manager; workers fan out below.
396managers:
397  maintainer:
398    runtime: claude-code
399    role_prompt: roles/maintainer.md
400    # `teamctl bot setup` writes the interfaces.telegram block here.
401
402workers:
403  bug_fix:
404    runtime: claude-code  # workers default to sonnet
405    reports_to: maintainer
406";
407        fs::write(&path, fixture).unwrap();
408
409        let doc = load(&path).unwrap();
410        let doc = set_nested_mapping(
411            doc,
412            &["managers", "maintainer", "interfaces", "telegram"],
413            &[
414                ("bot_token_env", "TEAMCTL_TG_MAINTAINER_TOKEN"),
415                ("chat_ids_env", "TEAMCTL_TG_MAINTAINER_CHATS"),
416            ],
417        )
418        .unwrap();
419        save(&doc, &path).unwrap();
420
421        let after = fs::read_to_string(&path).unwrap();
422        assert!(
423            after.contains(
424                "# Hub-and-spoke: maintainer is the only manager; workers fan out below."
425            ),
426            "block comment dropped — regression class still open:\n{after}"
427        );
428        assert!(
429            after.contains("# `teamctl bot setup` writes the interfaces.telegram block here."),
430            "inline comment dropped:\n{after}"
431        );
432        assert!(
433            after.contains("# workers default to sonnet"),
434            "trailing line comment dropped:\n{after}"
435        );
436        assert!(after.contains("    interfaces:"));
437        assert!(after.contains("      telegram:"));
438        assert!(after.contains("        bot_token_env: TEAMCTL_TG_MAINTAINER_TOKEN"));
439        assert!(after.contains("        chat_ids_env: TEAMCTL_TG_MAINTAINER_CHATS"));
440    }
441
442    /// Idempotency: re-running set_nested_mapping with the same path
443    /// replaces the leaf in place rather than appending a duplicate.
444    /// Sibling adapters under the same parent survive.
445    #[test]
446    fn idempotent_replace_preserves_siblings() {
447        let dir = tempfile::tempdir().unwrap();
448        let path = dir.path().join("siblings.yaml");
449        let fixture = "\
450version: 2
451managers:
452  pm:
453    runtime: claude-code
454    interfaces:
455      discord:
456        bot_token_env: PM_DISCORD_TOKEN
457      telegram:
458        bot_token_env: OLD_TOKEN
459        chat_ids_env: OLD_CHATS
460";
461        fs::write(&path, fixture).unwrap();
462
463        let doc = load(&path).unwrap();
464        let doc = set_nested_mapping(
465            doc,
466            &["managers", "pm", "interfaces", "telegram"],
467            &[
468                ("bot_token_env", "NEW_TOKEN"),
469                ("chat_ids_env", "NEW_CHATS"),
470            ],
471        )
472        .unwrap();
473        save(&doc, &path).unwrap();
474
475        let after = fs::read_to_string(&path).unwrap();
476        assert_eq!(
477            after.matches("telegram:").count(),
478            1,
479            "duplicate telegram block:\n{after}"
480        );
481        assert_eq!(
482            after.matches("discord:").count(),
483            1,
484            "discord sibling lost:\n{after}"
485        );
486        assert!(
487            after.contains("PM_DISCORD_TOKEN"),
488            "discord adapter contents lost:\n{after}"
489        );
490        assert!(after.contains("NEW_TOKEN"));
491        assert!(after.contains("NEW_CHATS"));
492        assert!(!after.contains("OLD_TOKEN"));
493        assert!(!after.contains("OLD_CHATS"));
494    }
495}