1pub mod backup;
51pub mod locate;
52pub mod lock;
53pub mod splice;
54
55use std::io;
56use std::path::Path;
57
58use crate::report::ReportItem;
59
60pub struct EditCtx<'a> {
65 pub source: &'a str,
67 pub workload_path: &'a Path,
69}
70
71pub fn with_workload<F>(workload_path: &Path, mutate: F) -> io::Result<()>
83where
84 F: FnOnce(EditCtx<'_>) -> Result<String, String>,
85{
86 let _guard = lock::acquire(workload_path)?;
88
89 let source = std::fs::read_to_string(workload_path).map_err(|e| {
91 io::Error::new(e.kind(), format!("read '{}': {e}", workload_path.display()))
92 })?;
93
94 let new_source = mutate(EditCtx {
96 source: &source,
97 workload_path,
98 })
99 .map_err(|e| {
100 io::Error::new(
101 io::ErrorKind::InvalidData,
102 format!("workload edit '{}': {e}", workload_path.display()),
103 )
104 })?;
105
106 let _: serde_yaml::Value = serde_yaml::from_str(&new_source).map_err(|e| {
118 io::Error::new(
119 io::ErrorKind::InvalidData,
120 format!("post-edit YAML failed to parse: {e}\n\n--- new content ---\n{new_source}",),
121 )
122 })?;
123
124 let paths = backup::rotate(workload_path)?;
126
127 if let Err(e) = std::fs::write(&paths.temp, &new_source) {
129 let _ = backup::rollback(&paths);
130 return Err(io::Error::new(
131 e.kind(),
132 format!("write temp '{}': {e}", paths.temp.display()),
133 ));
134 }
135 if let Err(e) = backup::commit_temp(&paths) {
136 let _ = backup::rollback(&paths);
137 return Err(e);
138 }
139 Ok(())
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum AddOutcome {
146 Inserted,
148 Replaced,
151}
152
153#[derive(Debug, Clone)]
158pub enum Anchor {
159 Root,
161 Scenario(String),
163 Phase(String),
165 Op { phase: String, op: String },
167}
168
169impl Anchor {
170 pub fn report_path(&self) -> Vec<String> {
173 match self {
174 Anchor::Root => vec!["report".to_string()],
175 Anchor::Scenario(s) => vec!["scenarios".to_string(), s.clone(), "report".to_string()],
176 Anchor::Phase(p) => vec!["phases".to_string(), p.clone(), "report".to_string()],
177 Anchor::Op { phase, op } => vec![
178 "phases".to_string(),
179 phase.clone(),
180 "ops".to_string(),
181 op.clone(),
182 "report".to_string(),
183 ],
184 }
185 }
186
187 pub fn label(&self) -> String {
189 match self {
190 Anchor::Root => "root".to_string(),
191 Anchor::Scenario(s) => format!("scenario:{s}"),
192 Anchor::Phase(p) => format!("phase:{p}"),
193 Anchor::Op { phase, op } => format!("op:{phase}.{op}"),
194 }
195 }
196}
197
198pub fn add_item(
214 workload_path: &Path,
215 anchor: &Anchor,
216 group: &str,
217 item: &ReportItem,
218 replace: bool,
219) -> io::Result<AddOutcome> {
220 let mut outcome = AddOutcome::Inserted;
221 let captured_outcome = &mut outcome;
222 with_workload(workload_path, |ctx| {
223 let result = apply_add(ctx.source, anchor, group, item, replace)?;
224 *captured_outcome = result.outcome;
225 Ok(result.new_source)
226 })?;
227 Ok(outcome)
228}
229
230struct AddResult {
231 new_source: String,
232 outcome: AddOutcome,
233}
234
235fn apply_add(
236 source: &str,
237 anchor: &Anchor,
238 group: &str,
239 item: &ReportItem,
240 replace: bool,
241) -> Result<AddResult, String> {
242 let existing = find_existing_item(source, &item.name)?;
247 match existing {
248 Some(loc) if !replace => {
249 return Err(format!(
250 "report item '{}' already defined at {}; pass --replace to overwrite \
251 in place, or --rename <new> to add under a different name",
252 item.name, loc.label,
253 ));
254 }
255 Some(_loc) if replace => {
256 let new_source = replace_existing_item(source, &item.name, item)?;
260 return Ok(AddResult {
261 new_source,
262 outcome: AddOutcome::Replaced,
263 });
264 }
265 _ => {}
266 }
267
268 insert_new_item_at_anchor(source, anchor, group, item).map(|new_source| AddResult {
274 new_source,
275 outcome: AddOutcome::Inserted,
276 })
277}
278
279struct ExistingItemLocation {
284 label: String,
285}
286
287fn find_existing_item(source: &str, name: &str) -> Result<Option<ExistingItemLocation>, String> {
291 let v: serde_json::Value = match serde_yaml::from_str::<serde_json::Value>(source) {
294 Ok(v) => v,
295 Err(e) => return Err(format!("workload yaml parse: {e}")),
296 };
297 if let Some(report) = v.get("report")
298 && let Ok(parsed) = crate::report::parse_report(report)
299 && parsed.report.find(name).is_some()
300 {
301 return Ok(Some(ExistingItemLocation {
302 label: "root".to_string(),
303 }));
304 }
305 Ok(None)
307}
308
309fn replace_existing_item(
310 source: &str,
311 name: &str,
312 new_item: &ReportItem,
313) -> Result<String, String> {
314 let v: serde_json::Value = serde_yaml::from_str::<serde_json::Value>(source)
321 .map_err(|e| format!("workload yaml parse: {e}"))?;
322 let report_value = v
323 .get("report")
324 .ok_or_else(|| format!("no `report:` block to find item '{name}'"))?;
325 let parsed =
326 crate::report::parse_report(report_value).map_err(|e| format!("report parse: {e}"))?;
327
328 let group = parsed
329 .report
330 .groups
331 .iter()
332 .find(|g| g.items.iter().any(|i| i.name == name))
333 .ok_or_else(|| format!("item '{name}' not found in any report group"))?;
334
335 let mut new_group_body = String::new();
336 for it in &group.items {
337 let block = if it.name == name {
338 new_item.to_yaml_directive_string()
339 } else {
340 it.to_yaml_directive_string()
341 };
342 new_group_body.push_str(&block);
343 }
344
345 let tree = locate::parse(source)?;
347 let path: Vec<&str> = vec!["report", group.name.as_str()];
348 let located = locate::locate_path(&tree, source, &path)?;
349 let range = match located {
350 locate::Located::Found { range } => range,
351 locate::Located::Missing { .. } => {
352 return Err(format!(
353 "located group '{}' via parser but tree-sitter could not find it",
354 group.name,
355 ));
356 }
357 };
358
359 let block_scalar = format_as_block_scalar(&new_group_body, source, &range);
366 Ok(splice::replace_range(source, range, &block_scalar))
367}
368
369fn format_as_block_scalar(body: &str, source: &str, range: &std::ops::Range<usize>) -> String {
373 let line_start = source[..range.start]
378 .rfind('\n')
379 .map(|i| i + 1)
380 .unwrap_or(0);
381 let pre_value = &source[line_start..range.start];
382 let key_column = pre_value.find(|c: char| !c.is_whitespace()).unwrap_or(0);
386 let cont_indent = key_column + 2;
387 let pad = " ".repeat(cont_indent);
388
389 let mut out = String::new();
390 out.push_str("|\n");
391 for line in body.split_inclusive('\n') {
392 let trimmed = line.trim_end_matches('\n');
393 if trimmed.is_empty() {
394 out.push('\n');
395 continue;
396 }
397 out.push_str(&pad);
398 out.push_str(trimmed);
399 out.push('\n');
400 }
401 if out.ends_with('\n') {
406 out.pop();
407 }
408 out
409}
410
411fn insert_new_item_at_anchor(
412 source: &str,
413 anchor: &Anchor,
414 group: &str,
415 item: &ReportItem,
416) -> Result<String, String> {
417 let tree = locate::parse(source)?;
418 let report_path: Vec<String> = anchor.report_path();
419 let report_path_refs: Vec<&str> = report_path.iter().map(String::as_str).collect();
420
421 let located = locate::locate_path(&tree, source, &report_path_refs)?;
423 let group_path: Vec<&str> = {
424 let mut v = report_path_refs.clone();
425 v.push(group);
426 v
427 };
428 match located {
429 locate::Located::Found { range: _ } => {
430 let group_located = locate::locate_path(&tree, source, &group_path)?;
433 match group_located {
434 locate::Located::Found { range } => {
435 let existing_body = &source[range.clone()];
438 let block_for_existing_group = strip_block_scalar_indent(existing_body);
439 let new_body = format!(
440 "{}{}",
441 block_for_existing_group,
442 item.to_yaml_directive_string(),
443 );
444 let block_scalar = format_as_block_scalar(&new_body, source, &range);
445 Ok(splice::replace_range(source, range, &block_scalar))
446 }
447 locate::Located::Missing {
448 insert_at, indent, ..
449 } => {
450 let pad = " ".repeat(indent);
453 let block = format_new_group(group, item, indent);
454 let inserted = format!("{pad}{block}");
455 Ok(splice::insert_at(
456 source,
457 insert_at,
458 &ensure_leading_newline(source, insert_at, &inserted),
459 ))
460 }
461 }
462 }
463 locate::Located::Missing {
464 insert_at, indent, ..
465 } => {
466 match anchor {
471 Anchor::Root => {
472 let block = format_new_report_block(group, item, indent);
473 Ok(splice::insert_at(
474 source,
475 insert_at,
476 &ensure_leading_newline(source, insert_at, &block),
477 ))
478 }
479 _ => Err(format!(
480 "anchor {} not yet supported by Phase B (Phase D will materialise \
481 intermediate scope keys)",
482 anchor.label(),
483 )),
484 }
485 }
486 }
487}
488
489fn strip_block_scalar_indent(body: &str) -> String {
493 let lines: Vec<&str> = body.split('\n').collect();
495 let min_indent = lines
496 .iter()
497 .filter(|l| !l.trim().is_empty())
498 .map(|l| l.len() - l.trim_start_matches(' ').len())
499 .min()
500 .unwrap_or(0);
501 let mut out = String::new();
502 for (i, line) in lines.iter().enumerate() {
503 if i > 0 {
504 out.push('\n');
505 }
506 if line.len() >= min_indent {
507 out.push_str(&line[min_indent..]);
508 } else {
509 out.push_str(line);
510 }
511 }
512 if !out.ends_with('\n') {
513 out.push('\n');
514 }
515 out
516}
517
518fn format_new_group(group: &str, item: &ReportItem, indent: usize) -> String {
519 let pad = " ".repeat(indent + 2);
522 let mut out = String::new();
523 out.push_str(group);
524 out.push_str(": |\n");
525 for line in item.to_yaml_directive_string().split_inclusive('\n') {
526 let trimmed = line.trim_end_matches('\n');
527 if trimmed.is_empty() {
528 out.push('\n');
529 continue;
530 }
531 out.push_str(&pad);
532 out.push_str(trimmed);
533 out.push('\n');
534 }
535 out
536}
537
538fn format_new_report_block(group: &str, item: &ReportItem, indent: usize) -> String {
539 let outer = " ".repeat(indent);
542 let mut out = String::new();
543 out.push_str(&outer);
544 out.push_str("report:\n");
545 let inner = format_new_group(group, item, indent + 2);
546 out.push_str(&" ".repeat(indent + 2));
547 out.push_str(&inner);
548 out
549}
550
551fn ensure_leading_newline(source: &str, offset: usize, content: &str) -> String {
552 if offset == 0 || source.as_bytes()[offset - 1] == b'\n' {
556 content.to_string()
557 } else {
558 format!("\n{content}")
559 }
560}
561
562pub fn replace_item(workload_path: &Path, item: &ReportItem) -> io::Result<()> {
567 with_workload(workload_path, |ctx| {
568 replace_existing_item(ctx.source, &item.name, item)
569 })
570}
571
572pub fn rename_item(
589 workload_path: &Path,
590 old_name: &str,
591 new_name: &str,
592 replace: bool,
593) -> io::Result<()> {
594 with_workload(workload_path, |ctx| {
595 let v: serde_json::Value = serde_yaml::from_str::<serde_json::Value>(ctx.source)
596 .map_err(|e| format!("yaml parse: {e}"))?;
597 let report_value = v
598 .get("report")
599 .ok_or_else(|| format!("no `report:` block; cannot rename '{old_name}'"))?;
600 let parsed =
601 crate::report::parse_report(report_value).map_err(|e| format!("report parse: {e}"))?;
602 let existing = parsed
603 .report
604 .find(old_name)
605 .ok_or_else(|| format!("item '{old_name}' not found"))?;
606 if old_name == new_name {
607 return Err(format!(
608 "rename: <old> and <new> are both '{old_name}' — nothing to do"
609 ));
610 }
611 let target_collides = parsed.report.find(new_name).is_some();
612 if target_collides && !replace {
613 return Err(format!(
614 "rename target '{new_name}' is already in use; \
615 pass --replace to drop the existing item under \
616 '{new_name}' and rename '{old_name}' over it, or \
617 pick another name"
618 ));
619 }
620
621 let mut renamed: ReportItem = (*existing).clone();
622 renamed.name = new_name.to_string();
623
624 if target_collides {
625 let after_delete = remove_existing_item(ctx.source, new_name)?;
631 replace_existing_item(&after_delete, old_name, &renamed)
632 } else {
633 replace_existing_item(ctx.source, old_name, &renamed)
634 }
635 })
636}
637
638fn remove_existing_item(source: &str, name: &str) -> Result<String, String> {
642 let v: serde_json::Value = serde_yaml::from_str::<serde_json::Value>(source)
643 .map_err(|e| format!("workload yaml parse: {e}"))?;
644 let report_value = v
645 .get("report")
646 .ok_or_else(|| format!("no `report:` block; cannot remove '{name}'"))?;
647 let parsed =
648 crate::report::parse_report(report_value).map_err(|e| format!("report parse: {e}"))?;
649
650 let group = parsed
651 .report
652 .groups
653 .iter()
654 .find(|g| g.items.iter().any(|i| i.name == name))
655 .ok_or_else(|| format!("item '{name}' not found in any report group"))?;
656
657 let mut new_group_body = String::new();
658 for it in &group.items {
659 if it.name == name {
660 continue;
661 }
662 new_group_body.push_str(&it.to_yaml_directive_string());
663 }
664
665 if new_group_body.is_empty() {
671 new_group_body.push('\n');
672 }
673
674 let tree = locate::parse(source)?;
675 let path: Vec<&str> = vec!["report", group.name.as_str()];
676 let located = locate::locate_path(&tree, source, &path)?;
677 let range = match located {
678 locate::Located::Found { range } => range,
679 locate::Located::Missing { .. } => {
680 return Err(format!(
681 "located group '{}' via parser but tree-sitter could not find it",
682 group.name,
683 ));
684 }
685 };
686 let block_scalar = format_as_block_scalar(&new_group_body, source, &range);
687 Ok(splice::replace_range(source, range, &block_scalar))
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use crate::report::Kind;
694
695 fn fresh_workload(label: &str, content: &str) -> std::path::PathBuf {
696 let p = std::env::temp_dir().join(format!("nmbrs-edit-{label}-{}", std::process::id(),));
697 let _ = std::fs::remove_dir_all(&p);
698 std::fs::create_dir_all(&p).unwrap();
699 let path = p.join("w.yaml");
700 std::fs::write(&path, content).unwrap();
701 path
702 }
703
704 #[test]
705 fn add_item_inserts_new_report_block_when_none_exists() {
706 let path = fresh_workload(
707 "add_root_no_report",
708 concat!(
709 "phases:\n",
710 " setup:\n",
711 " ops:\n",
712 " step: noop\n",
713 ),
714 );
715 let item = ReportItem {
716 kind: Kind::Plot,
717 name: "demo".to_string(),
718 label: Some("Demo".to_string()),
719 body: "over cycle\nmetric=throughput".to_string(),
720 ..Default::default()
721 };
722 let outcome = add_item(&path, &Anchor::Root, "cli_added", &item, false).expect("add");
723 assert_eq!(outcome, AddOutcome::Inserted);
724 let content = std::fs::read_to_string(&path).unwrap();
725 assert!(
726 content.contains("report:"),
727 "should have inserted report:\n{content}"
728 );
729 assert!(content.contains("cli_added"));
730 assert!(content.contains("plot demo"));
731 assert!(content.contains("phases:"));
733 assert!(content.contains("step: noop"));
734 }
735
736 #[test]
737 fn add_item_appends_to_existing_group_in_existing_report() {
738 let path = fresh_workload(
739 "add_to_existing",
740 concat!(
741 "report:\n",
742 " cli_added: |\n",
743 " plot first\n",
744 " over cycle\n",
745 "phases:\n",
746 " setup:\n",
747 " ops:\n",
748 " step: noop\n",
749 ),
750 );
751 let item = ReportItem {
752 kind: Kind::Plot,
753 name: "second".to_string(),
754 body: "over cycle".to_string(),
755 ..Default::default()
756 };
757 let outcome = add_item(&path, &Anchor::Root, "cli_added", &item, false).expect("add");
758 assert_eq!(outcome, AddOutcome::Inserted);
759 let content = std::fs::read_to_string(&path).unwrap();
760 assert!(content.contains("plot first"));
761 assert!(content.contains("plot second"));
762 assert!(content.contains("step: noop"));
763 }
764
765 #[test]
766 fn add_item_collision_errors_without_replace() {
767 let path = fresh_workload(
768 "add_collision",
769 concat!(
770 "report:\n",
771 " cli_added: |\n",
772 " plot demo\n",
773 " over cycle\n",
774 ),
775 );
776 let item = ReportItem {
777 kind: Kind::Plot,
778 name: "demo".to_string(),
779 body: "over cycle".to_string(),
780 ..Default::default()
781 };
782 let err = add_item(&path, &Anchor::Root, "cli_added", &item, false).unwrap_err();
783 assert!(err.to_string().contains("already defined"), "got: {err}");
784 let content = std::fs::read_to_string(&path).unwrap();
786 assert!(content.contains("plot demo"));
787 }
790
791 #[test]
792 fn add_item_replace_overwrites_in_place() {
793 let path = fresh_workload(
794 "replace_inplace",
795 concat!(
796 "report:\n",
797 " cli_added: |\n",
798 " plot demo\n",
799 " over cycle\n",
800 " label \"v1\"\n",
801 ),
802 );
803 let item = ReportItem {
804 kind: Kind::Plot,
805 name: "demo".to_string(),
806 label: Some("v2".to_string()),
807 body: "over cycle".to_string(),
808 ..Default::default()
809 };
810 let outcome = add_item(&path, &Anchor::Root, "cli_added", &item, true).expect("replace");
811 assert_eq!(outcome, AddOutcome::Replaced);
812 let content = std::fs::read_to_string(&path).unwrap();
813 assert!(content.contains("v2"));
814 assert!(
815 !content.contains("v1"),
816 "old label should be gone, got:\n{content}"
817 );
818
819 let bak = path.with_extension("yaml.bak");
821 let paths = backup::BackupPaths::for_workload(&path);
825 let _ = bak;
826 assert!(paths.bak.exists());
827 let bak_content = std::fs::read_to_string(&paths.bak).unwrap();
828 assert!(
829 bak_content.contains("v1"),
830 ".bak should hold pre-edit content"
831 );
832 }
833
834 #[test]
835 fn rename_item_updates_name_and_writes_backup() {
836 let path = fresh_workload(
837 "rename",
838 concat!(
839 "report:\n",
840 " cli_added: |\n",
841 " plot demo\n",
842 " over cycle\n",
843 ),
844 );
845 rename_item(&path, "demo", "demo_v2", false).expect("rename");
846 let content = std::fs::read_to_string(&path).unwrap();
847 assert!(content.contains("plot demo_v2"));
848 assert!(
849 !content.contains("plot demo\n"),
850 "original name should be gone:\n{content}"
851 );
852 let paths = backup::BackupPaths::for_workload(&path);
853 assert!(paths.bak.exists());
854 }
855
856 #[test]
857 fn rename_target_collision_errors_without_replace() {
858 let path = fresh_workload(
859 "rename_collision",
860 concat!(
861 "report:\n",
862 " cli_added: |\n",
863 " plot a\n",
864 " over cycle\n",
865 " plot b\n",
866 " over cycle\n",
867 ),
868 );
869 let err = rename_item(&path, "a", "b", false).unwrap_err();
870 assert!(err.to_string().contains("already in use"), "got: {err}");
871 assert!(
872 err.to_string().contains("--replace"),
873 "should hint at the remediation flag: {err}"
874 );
875 }
876
877 #[test]
878 fn rename_target_collision_with_replace_drops_existing_target() {
879 let path = fresh_workload(
880 "rename_collision_replace",
881 concat!(
882 "report:\n",
883 " cli_added: |\n",
884 " plot a\n",
885 " label \"keep this spec\"\n",
886 " over cycle\n",
887 " plot b\n",
888 " label \"drop this spec\"\n",
889 " over cycle\n",
890 ),
891 );
892 rename_item(&path, "a", "b", true).expect("destructive rename");
893 let content = std::fs::read_to_string(&path).unwrap();
894 let b_count = content.matches("plot b").count();
896 assert_eq!(
897 b_count, 1,
898 "should have exactly one `plot b`, got:\n{content}"
899 );
900 assert!(
902 !content.contains("plot a\n"),
903 "original `a` should be gone:\n{content}"
904 );
905 assert!(
907 content.contains("keep this spec"),
908 "`a`'s spec should have survived under name `b`:\n{content}"
909 );
910 assert!(
911 !content.contains("drop this spec"),
912 "`b`'s old spec should be gone:\n{content}"
913 );
914 }
915
916 #[test]
917 fn rename_same_name_is_a_noop_error() {
918 let path = fresh_workload(
919 "rename_same",
920 concat!(
921 "report:\n",
922 " cli_added: |\n",
923 " plot demo\n",
924 " over cycle\n",
925 ),
926 );
927 let err = rename_item(&path, "demo", "demo", false).unwrap_err();
928 assert!(err.to_string().contains("nothing to do"), "got: {err}");
929 }
930
931 #[test]
932 fn malformed_mutation_aborts_without_committing() {
933 let path = fresh_workload(
934 "malformed",
935 concat!(
936 "report:\n",
937 " cli_added: |\n",
938 " plot demo\n",
939 " over cycle\n",
940 ),
941 );
942 let original = std::fs::read_to_string(&path).unwrap();
943
944 let err = with_workload(&path, |_ctx| {
945 Ok("\"unbalanced".to_string())
948 })
949 .unwrap_err();
950 assert!(err.to_string().contains("failed to parse"), "got: {err}");
951
952 let post = std::fs::read_to_string(&path).unwrap();
953 assert_eq!(
954 post, original,
955 "workload must be byte-identical after a failed mutation"
956 );
957 }
958
959 #[test]
960 fn anchor_report_path_shapes() {
961 assert_eq!(Anchor::Root.report_path(), vec!["report"]);
962 assert_eq!(
963 Anchor::Scenario("foo".into()).report_path(),
964 vec!["scenarios", "foo", "report"]
965 );
966 assert_eq!(
967 Anchor::Phase("setup".into()).report_path(),
968 vec!["phases", "setup", "report"]
969 );
970 assert_eq!(
971 Anchor::Op {
972 phase: "p".into(),
973 op: "o".into()
974 }
975 .report_path(),
976 vec!["phases", "p", "ops", "o", "report"]
977 );
978 }
979
980 #[allow(dead_code)]
981 fn require_kind() {
982 let _ = Kind::Plot;
985 }
986
987 #[test]
988 fn comments_outside_edit_range_survive_byte_identical() {
989 let source = concat!(
993 "# Top-level workload comment\n",
994 "# Multi-line\n",
995 "params:\n",
996 " cycles: \"100\" # inline comment on cycles\n",
997 "\n",
998 "# Comment between blocks\n",
999 "report:\n",
1000 " cli_added: |\n",
1001 " plot demo\n",
1002 " over cycle\n",
1003 "\n",
1004 "phases:\n",
1005 " # phase-block comment\n",
1006 " setup:\n",
1007 " ops:\n",
1008 " step: noop\n",
1009 );
1010 let path = fresh_workload("comments_survive", source);
1011
1012 let item = ReportItem {
1013 kind: Kind::Plot,
1014 name: "demo".to_string(),
1015 label: Some("Updated".to_string()),
1016 body: "over cycle".to_string(),
1017 ..Default::default()
1018 };
1019 add_item(&path, &Anchor::Root, "cli_added", &item, true).expect("replace");
1020
1021 let post = std::fs::read_to_string(&path).unwrap();
1022 for c in [
1024 "# Top-level workload comment",
1025 "# Multi-line",
1026 "# inline comment on cycles",
1027 "# Comment between blocks",
1028 "# phase-block comment",
1029 ] {
1030 assert!(post.contains(c), "missing comment {c:?} in:\n{post}");
1031 }
1032 let unrelated = "phases:\n # phase-block comment\n setup:\n ops:\n step: noop\n";
1035 assert!(
1036 post.contains(unrelated),
1037 "phases block changed; got:\n{post}"
1038 );
1039 }
1040
1041 #[test]
1042 fn quote_styles_outside_edit_range_preserved() {
1043 let source = concat!(
1044 "params:\n",
1045 " a: \"double\"\n",
1046 " b: 'single'\n",
1047 " c: bare\n",
1048 "report:\n",
1049 " cli_added: |\n",
1050 " plot demo\n",
1051 " over cycle\n",
1052 );
1053 let path = fresh_workload("quotes_preserved", source);
1054 let item = ReportItem {
1055 kind: Kind::Plot,
1056 name: "demo".to_string(),
1057 label: Some("X".to_string()),
1058 body: "over cycle".to_string(),
1059 ..Default::default()
1060 };
1061 add_item(&path, &Anchor::Root, "cli_added", &item, true).expect("replace");
1062 let post = std::fs::read_to_string(&path).unwrap();
1063 assert!(post.contains("a: \"double\""), "double quotes lost: {post}");
1064 assert!(post.contains("b: 'single'"), "single quotes lost: {post}");
1065 assert!(post.contains("c: bare"), "bare scalar lost: {post}");
1066 }
1067}