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_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 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 Ok(joined)
126}
127
128pub 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
160fn 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 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; 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 let insert_indent = if existing_depth == path.len() {
207 leaf_replace_range.expect("leaf existed").2
209 } else {
210 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 let value_indent = if existing_depth == path.len() {
233 insert_indent + 2
234 } else {
235 indent
236 };
237 if existing_depth == path.len() {
238 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 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 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
274fn 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 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 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
309fn 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 if key.contains(':') {
332 return None;
333 }
334 let after = &trimmed[colon_idx + 1..];
337 if !after.is_empty() && !after.starts_with(char::is_whitespace) {
338 return None;
340 }
341 Some((indent, key))
342}
343
344fn block_end_after(lines: &[&str], key_line: usize, key_indent: usize) -> usize {
366 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 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 assert!(
470 after.contains("\n eng_lead:"),
471 "eng_lead boundary broken:\n{after}"
472 );
473 }
474
475 #[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 #[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 assert!(
577 after.contains(" bot_token_env: NEW_TOKEN"),
578 "leaf not replaced:\n{after}"
579 );
580 assert!(
582 after.contains("# operator note: keep this trailing footer"),
583 "file-final trailing comment eaten on leaf replace (#319):\n{after}"
584 );
585 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 #[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 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 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 #[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 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 #[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 assert!(
744 after.contains(" # deeply indented operator note"),
745 "deeper-indented trailing comment corrupted on leaf replace (#319):\n{after}"
746 );
747 }
748
749 #[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 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 assert!(
800 !after.ends_with('\n'),
801 "splice must not introduce a trailing newline the source lacked:\n{after:?}"
802 );
803 }
804
805 #[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 #[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 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 #[test]
921 fn set_top_level_scalar_replaces_integer_with_quoted_string() {
922 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 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 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}