1use 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
45pub 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
56pub fn save(doc: &Document, path: &Path) -> Result<()> {
62 fs::write(path, doc.to_string()).with_context(|| format!("write {}", path.display()))?;
63 Ok(())
64}
65
66pub 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
98fn 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 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; 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 let insert_indent = if existing_depth == path.len() {
145 leaf_replace_range.expect("leaf existed").2
147 } else {
148 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 let value_indent = indent;
163 if existing_depth == path.len() {
164 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 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 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
200fn 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 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 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
235fn 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 if key.contains(':') {
258 return None;
259 }
260 let after = &trimmed[colon_idx + 1..];
263 if !after.is_empty() && !after.starts_with(char::is_whitespace) {
264 return None;
266 }
267 Some((indent, key))
268}
269
270fn 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 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 assert!(
375 after.contains("\n eng_lead:"),
376 "eng_lead boundary broken:\n{after}"
377 );
378 }
379
380 #[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 #[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}