pub mod backup;
pub mod locate;
pub mod lock;
pub mod splice;
use std::io;
use std::path::Path;
use crate::report::ReportItem;
pub struct EditCtx<'a> {
pub source: &'a str,
pub workload_path: &'a Path,
}
pub fn with_workload<F>(workload_path: &Path, mutate: F) -> io::Result<()>
where
F: FnOnce(EditCtx<'_>) -> Result<String, String>,
{
let _guard = lock::acquire(workload_path)?;
let source = std::fs::read_to_string(workload_path).map_err(|e| {
io::Error::new(e.kind(), format!("read '{}': {e}", workload_path.display()))
})?;
let new_source = mutate(EditCtx {
source: &source,
workload_path,
})
.map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("workload edit '{}': {e}", workload_path.display()),
)
})?;
let _: serde_yaml::Value = serde_yaml::from_str(&new_source).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("post-edit YAML failed to parse: {e}\n\n--- new content ---\n{new_source}",),
)
})?;
let paths = backup::rotate(workload_path)?;
if let Err(e) = std::fs::write(&paths.temp, &new_source) {
let _ = backup::rollback(&paths);
return Err(io::Error::new(
e.kind(),
format!("write temp '{}': {e}", paths.temp.display()),
));
}
if let Err(e) = backup::commit_temp(&paths) {
let _ = backup::rollback(&paths);
return Err(e);
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddOutcome {
Inserted,
Replaced,
}
#[derive(Debug, Clone)]
pub enum Anchor {
Root,
Scenario(String),
Phase(String),
Op { phase: String, op: String },
}
impl Anchor {
pub fn report_path(&self) -> Vec<String> {
match self {
Anchor::Root => vec!["report".to_string()],
Anchor::Scenario(s) => vec!["scenarios".to_string(), s.clone(), "report".to_string()],
Anchor::Phase(p) => vec!["phases".to_string(), p.clone(), "report".to_string()],
Anchor::Op { phase, op } => vec![
"phases".to_string(),
phase.clone(),
"ops".to_string(),
op.clone(),
"report".to_string(),
],
}
}
pub fn label(&self) -> String {
match self {
Anchor::Root => "root".to_string(),
Anchor::Scenario(s) => format!("scenario:{s}"),
Anchor::Phase(p) => format!("phase:{p}"),
Anchor::Op { phase, op } => format!("op:{phase}.{op}"),
}
}
}
pub fn add_item(
workload_path: &Path,
anchor: &Anchor,
group: &str,
item: &ReportItem,
replace: bool,
) -> io::Result<AddOutcome> {
let mut outcome = AddOutcome::Inserted;
let captured_outcome = &mut outcome;
with_workload(workload_path, |ctx| {
let result = apply_add(ctx.source, anchor, group, item, replace)?;
*captured_outcome = result.outcome;
Ok(result.new_source)
})?;
Ok(outcome)
}
struct AddResult {
new_source: String,
outcome: AddOutcome,
}
fn apply_add(
source: &str,
anchor: &Anchor,
group: &str,
item: &ReportItem,
replace: bool,
) -> Result<AddResult, String> {
let existing = find_existing_item(source, &item.name)?;
match existing {
Some(loc) if !replace => {
return Err(format!(
"report item '{}' already defined at {}; pass --replace to overwrite \
in place, or --rename <new> to add under a different name",
item.name, loc.label,
));
}
Some(_loc) if replace => {
let new_source = replace_existing_item(source, &item.name, item)?;
return Ok(AddResult {
new_source,
outcome: AddOutcome::Replaced,
});
}
_ => {}
}
insert_new_item_at_anchor(source, anchor, group, item).map(|new_source| AddResult {
new_source,
outcome: AddOutcome::Inserted,
})
}
struct ExistingItemLocation {
label: String,
}
fn find_existing_item(source: &str, name: &str) -> Result<Option<ExistingItemLocation>, String> {
let v: serde_json::Value = match serde_yaml::from_str::<serde_json::Value>(source) {
Ok(v) => v,
Err(e) => return Err(format!("workload yaml parse: {e}")),
};
if let Some(report) = v.get("report")
&& let Ok(parsed) = crate::report::parse_report(report)
&& parsed.report.find(name).is_some()
{
return Ok(Some(ExistingItemLocation {
label: "root".to_string(),
}));
}
Ok(None)
}
fn replace_existing_item(
source: &str,
name: &str,
new_item: &ReportItem,
) -> Result<String, String> {
let v: serde_json::Value = serde_yaml::from_str::<serde_json::Value>(source)
.map_err(|e| format!("workload yaml parse: {e}"))?;
let report_value = v
.get("report")
.ok_or_else(|| format!("no `report:` block to find item '{name}'"))?;
let parsed =
crate::report::parse_report(report_value).map_err(|e| format!("report parse: {e}"))?;
let group = parsed
.report
.groups
.iter()
.find(|g| g.items.iter().any(|i| i.name == name))
.ok_or_else(|| format!("item '{name}' not found in any report group"))?;
let mut new_group_body = String::new();
for it in &group.items {
let block = if it.name == name {
new_item.to_yaml_directive_string()
} else {
it.to_yaml_directive_string()
};
new_group_body.push_str(&block);
}
let tree = locate::parse(source)?;
let path: Vec<&str> = vec!["report", group.name.as_str()];
let located = locate::locate_path(&tree, source, &path)?;
let range = match located {
locate::Located::Found { range } => range,
locate::Located::Missing { .. } => {
return Err(format!(
"located group '{}' via parser but tree-sitter could not find it",
group.name,
));
}
};
let block_scalar = format_as_block_scalar(&new_group_body, source, &range);
Ok(splice::replace_range(source, range, &block_scalar))
}
fn format_as_block_scalar(body: &str, source: &str, range: &std::ops::Range<usize>) -> String {
let line_start = source[..range.start]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
let pre_value = &source[line_start..range.start];
let key_column = pre_value.find(|c: char| !c.is_whitespace()).unwrap_or(0);
let cont_indent = key_column + 2;
let pad = " ".repeat(cont_indent);
let mut out = String::new();
out.push_str("|\n");
for line in body.split_inclusive('\n') {
let trimmed = line.trim_end_matches('\n');
if trimmed.is_empty() {
out.push('\n');
continue;
}
out.push_str(&pad);
out.push_str(trimmed);
out.push('\n');
}
if out.ends_with('\n') {
out.pop();
}
out
}
fn insert_new_item_at_anchor(
source: &str,
anchor: &Anchor,
group: &str,
item: &ReportItem,
) -> Result<String, String> {
let tree = locate::parse(source)?;
let report_path: Vec<String> = anchor.report_path();
let report_path_refs: Vec<&str> = report_path.iter().map(String::as_str).collect();
let located = locate::locate_path(&tree, source, &report_path_refs)?;
let group_path: Vec<&str> = {
let mut v = report_path_refs.clone();
v.push(group);
v
};
match located {
locate::Located::Found { range: _ } => {
let group_located = locate::locate_path(&tree, source, &group_path)?;
match group_located {
locate::Located::Found { range } => {
let existing_body = &source[range.clone()];
let block_for_existing_group = strip_block_scalar_indent(existing_body);
let new_body = format!(
"{}{}",
block_for_existing_group,
item.to_yaml_directive_string(),
);
let block_scalar = format_as_block_scalar(&new_body, source, &range);
Ok(splice::replace_range(source, range, &block_scalar))
}
locate::Located::Missing {
insert_at, indent, ..
} => {
let pad = " ".repeat(indent);
let block = format_new_group(group, item, indent);
let inserted = format!("{pad}{block}");
Ok(splice::insert_at(
source,
insert_at,
&ensure_leading_newline(source, insert_at, &inserted),
))
}
}
}
locate::Located::Missing {
insert_at, indent, ..
} => {
match anchor {
Anchor::Root => {
let block = format_new_report_block(group, item, indent);
Ok(splice::insert_at(
source,
insert_at,
&ensure_leading_newline(source, insert_at, &block),
))
}
_ => Err(format!(
"anchor {} not yet supported by Phase B (Phase D will materialise \
intermediate scope keys)",
anchor.label(),
)),
}
}
}
}
fn strip_block_scalar_indent(body: &str) -> String {
let lines: Vec<&str> = body.split('\n').collect();
let min_indent = lines
.iter()
.filter(|l| !l.trim().is_empty())
.map(|l| l.len() - l.trim_start_matches(' ').len())
.min()
.unwrap_or(0);
let mut out = String::new();
for (i, line) in lines.iter().enumerate() {
if i > 0 {
out.push('\n');
}
if line.len() >= min_indent {
out.push_str(&line[min_indent..]);
} else {
out.push_str(line);
}
}
if !out.ends_with('\n') {
out.push('\n');
}
out
}
fn format_new_group(group: &str, item: &ReportItem, indent: usize) -> String {
let pad = " ".repeat(indent + 2);
let mut out = String::new();
out.push_str(group);
out.push_str(": |\n");
for line in item.to_yaml_directive_string().split_inclusive('\n') {
let trimmed = line.trim_end_matches('\n');
if trimmed.is_empty() {
out.push('\n');
continue;
}
out.push_str(&pad);
out.push_str(trimmed);
out.push('\n');
}
out
}
fn format_new_report_block(group: &str, item: &ReportItem, indent: usize) -> String {
let outer = " ".repeat(indent);
let mut out = String::new();
out.push_str(&outer);
out.push_str("report:\n");
let inner = format_new_group(group, item, indent + 2);
out.push_str(&" ".repeat(indent + 2));
out.push_str(&inner);
out
}
fn ensure_leading_newline(source: &str, offset: usize, content: &str) -> String {
if offset == 0 || source.as_bytes()[offset - 1] == b'\n' {
content.to_string()
} else {
format!("\n{content}")
}
}
pub fn replace_item(workload_path: &Path, item: &ReportItem) -> io::Result<()> {
with_workload(workload_path, |ctx| {
replace_existing_item(ctx.source, &item.name, item)
})
}
pub fn rename_item(
workload_path: &Path,
old_name: &str,
new_name: &str,
replace: bool,
) -> io::Result<()> {
with_workload(workload_path, |ctx| {
let v: serde_json::Value = serde_yaml::from_str::<serde_json::Value>(ctx.source)
.map_err(|e| format!("yaml parse: {e}"))?;
let report_value = v
.get("report")
.ok_or_else(|| format!("no `report:` block; cannot rename '{old_name}'"))?;
let parsed =
crate::report::parse_report(report_value).map_err(|e| format!("report parse: {e}"))?;
let existing = parsed
.report
.find(old_name)
.ok_or_else(|| format!("item '{old_name}' not found"))?;
if old_name == new_name {
return Err(format!(
"rename: <old> and <new> are both '{old_name}' — nothing to do"
));
}
let target_collides = parsed.report.find(new_name).is_some();
if target_collides && !replace {
return Err(format!(
"rename target '{new_name}' is already in use; \
pass --replace to drop the existing item under \
'{new_name}' and rename '{old_name}' over it, or \
pick another name"
));
}
let mut renamed: ReportItem = (*existing).clone();
renamed.name = new_name.to_string();
if target_collides {
let after_delete = remove_existing_item(ctx.source, new_name)?;
replace_existing_item(&after_delete, old_name, &renamed)
} else {
replace_existing_item(ctx.source, old_name, &renamed)
}
})
}
fn remove_existing_item(source: &str, name: &str) -> Result<String, String> {
let v: serde_json::Value = serde_yaml::from_str::<serde_json::Value>(source)
.map_err(|e| format!("workload yaml parse: {e}"))?;
let report_value = v
.get("report")
.ok_or_else(|| format!("no `report:` block; cannot remove '{name}'"))?;
let parsed =
crate::report::parse_report(report_value).map_err(|e| format!("report parse: {e}"))?;
let group = parsed
.report
.groups
.iter()
.find(|g| g.items.iter().any(|i| i.name == name))
.ok_or_else(|| format!("item '{name}' not found in any report group"))?;
let mut new_group_body = String::new();
for it in &group.items {
if it.name == name {
continue;
}
new_group_body.push_str(&it.to_yaml_directive_string());
}
if new_group_body.is_empty() {
new_group_body.push('\n');
}
let tree = locate::parse(source)?;
let path: Vec<&str> = vec!["report", group.name.as_str()];
let located = locate::locate_path(&tree, source, &path)?;
let range = match located {
locate::Located::Found { range } => range,
locate::Located::Missing { .. } => {
return Err(format!(
"located group '{}' via parser but tree-sitter could not find it",
group.name,
));
}
};
let block_scalar = format_as_block_scalar(&new_group_body, source, &range);
Ok(splice::replace_range(source, range, &block_scalar))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::Kind;
fn fresh_workload(label: &str, content: &str) -> std::path::PathBuf {
let p = std::env::temp_dir().join(format!("nmbrs-edit-{label}-{}", std::process::id(),));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
let path = p.join("w.yaml");
std::fs::write(&path, content).unwrap();
path
}
#[test]
fn add_item_inserts_new_report_block_when_none_exists() {
let path = fresh_workload(
"add_root_no_report",
concat!(
"phases:\n",
" setup:\n",
" ops:\n",
" step: noop\n",
),
);
let item = ReportItem {
kind: Kind::Plot,
name: "demo".to_string(),
label: Some("Demo".to_string()),
body: "over cycle\nmetric=throughput".to_string(),
..Default::default()
};
let outcome = add_item(&path, &Anchor::Root, "cli_added", &item, false).expect("add");
assert_eq!(outcome, AddOutcome::Inserted);
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.contains("report:"),
"should have inserted report:\n{content}"
);
assert!(content.contains("cli_added"));
assert!(content.contains("plot demo"));
assert!(content.contains("phases:"));
assert!(content.contains("step: noop"));
}
#[test]
fn add_item_appends_to_existing_group_in_existing_report() {
let path = fresh_workload(
"add_to_existing",
concat!(
"report:\n",
" cli_added: |\n",
" plot first\n",
" over cycle\n",
"phases:\n",
" setup:\n",
" ops:\n",
" step: noop\n",
),
);
let item = ReportItem {
kind: Kind::Plot,
name: "second".to_string(),
body: "over cycle".to_string(),
..Default::default()
};
let outcome = add_item(&path, &Anchor::Root, "cli_added", &item, false).expect("add");
assert_eq!(outcome, AddOutcome::Inserted);
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("plot first"));
assert!(content.contains("plot second"));
assert!(content.contains("step: noop"));
}
#[test]
fn add_item_collision_errors_without_replace() {
let path = fresh_workload(
"add_collision",
concat!(
"report:\n",
" cli_added: |\n",
" plot demo\n",
" over cycle\n",
),
);
let item = ReportItem {
kind: Kind::Plot,
name: "demo".to_string(),
body: "over cycle".to_string(),
..Default::default()
};
let err = add_item(&path, &Anchor::Root, "cli_added", &item, false).unwrap_err();
assert!(err.to_string().contains("already defined"), "got: {err}");
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("plot demo"));
}
#[test]
fn add_item_replace_overwrites_in_place() {
let path = fresh_workload(
"replace_inplace",
concat!(
"report:\n",
" cli_added: |\n",
" plot demo\n",
" over cycle\n",
" label \"v1\"\n",
),
);
let item = ReportItem {
kind: Kind::Plot,
name: "demo".to_string(),
label: Some("v2".to_string()),
body: "over cycle".to_string(),
..Default::default()
};
let outcome = add_item(&path, &Anchor::Root, "cli_added", &item, true).expect("replace");
assert_eq!(outcome, AddOutcome::Replaced);
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("v2"));
assert!(
!content.contains("v1"),
"old label should be gone, got:\n{content}"
);
let bak = path.with_extension("yaml.bak");
let paths = backup::BackupPaths::for_workload(&path);
let _ = bak;
assert!(paths.bak.exists());
let bak_content = std::fs::read_to_string(&paths.bak).unwrap();
assert!(
bak_content.contains("v1"),
".bak should hold pre-edit content"
);
}
#[test]
fn rename_item_updates_name_and_writes_backup() {
let path = fresh_workload(
"rename",
concat!(
"report:\n",
" cli_added: |\n",
" plot demo\n",
" over cycle\n",
),
);
rename_item(&path, "demo", "demo_v2", false).expect("rename");
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("plot demo_v2"));
assert!(
!content.contains("plot demo\n"),
"original name should be gone:\n{content}"
);
let paths = backup::BackupPaths::for_workload(&path);
assert!(paths.bak.exists());
}
#[test]
fn rename_target_collision_errors_without_replace() {
let path = fresh_workload(
"rename_collision",
concat!(
"report:\n",
" cli_added: |\n",
" plot a\n",
" over cycle\n",
" plot b\n",
" over cycle\n",
),
);
let err = rename_item(&path, "a", "b", false).unwrap_err();
assert!(err.to_string().contains("already in use"), "got: {err}");
assert!(
err.to_string().contains("--replace"),
"should hint at the remediation flag: {err}"
);
}
#[test]
fn rename_target_collision_with_replace_drops_existing_target() {
let path = fresh_workload(
"rename_collision_replace",
concat!(
"report:\n",
" cli_added: |\n",
" plot a\n",
" label \"keep this spec\"\n",
" over cycle\n",
" plot b\n",
" label \"drop this spec\"\n",
" over cycle\n",
),
);
rename_item(&path, "a", "b", true).expect("destructive rename");
let content = std::fs::read_to_string(&path).unwrap();
let b_count = content.matches("plot b").count();
assert_eq!(
b_count, 1,
"should have exactly one `plot b`, got:\n{content}"
);
assert!(
!content.contains("plot a\n"),
"original `a` should be gone:\n{content}"
);
assert!(
content.contains("keep this spec"),
"`a`'s spec should have survived under name `b`:\n{content}"
);
assert!(
!content.contains("drop this spec"),
"`b`'s old spec should be gone:\n{content}"
);
}
#[test]
fn rename_same_name_is_a_noop_error() {
let path = fresh_workload(
"rename_same",
concat!(
"report:\n",
" cli_added: |\n",
" plot demo\n",
" over cycle\n",
),
);
let err = rename_item(&path, "demo", "demo", false).unwrap_err();
assert!(err.to_string().contains("nothing to do"), "got: {err}");
}
#[test]
fn malformed_mutation_aborts_without_committing() {
let path = fresh_workload(
"malformed",
concat!(
"report:\n",
" cli_added: |\n",
" plot demo\n",
" over cycle\n",
),
);
let original = std::fs::read_to_string(&path).unwrap();
let err = with_workload(&path, |_ctx| {
Ok("\"unbalanced".to_string())
})
.unwrap_err();
assert!(err.to_string().contains("failed to parse"), "got: {err}");
let post = std::fs::read_to_string(&path).unwrap();
assert_eq!(
post, original,
"workload must be byte-identical after a failed mutation"
);
}
#[test]
fn anchor_report_path_shapes() {
assert_eq!(Anchor::Root.report_path(), vec!["report"]);
assert_eq!(
Anchor::Scenario("foo".into()).report_path(),
vec!["scenarios", "foo", "report"]
);
assert_eq!(
Anchor::Phase("setup".into()).report_path(),
vec!["phases", "setup", "report"]
);
assert_eq!(
Anchor::Op {
phase: "p".into(),
op: "o".into()
}
.report_path(),
vec!["phases", "p", "ops", "o", "report"]
);
}
#[allow(dead_code)]
fn require_kind() {
let _ = Kind::Plot;
}
#[test]
fn comments_outside_edit_range_survive_byte_identical() {
let source = concat!(
"# Top-level workload comment\n",
"# Multi-line\n",
"params:\n",
" cycles: \"100\" # inline comment on cycles\n",
"\n",
"# Comment between blocks\n",
"report:\n",
" cli_added: |\n",
" plot demo\n",
" over cycle\n",
"\n",
"phases:\n",
" # phase-block comment\n",
" setup:\n",
" ops:\n",
" step: noop\n",
);
let path = fresh_workload("comments_survive", source);
let item = ReportItem {
kind: Kind::Plot,
name: "demo".to_string(),
label: Some("Updated".to_string()),
body: "over cycle".to_string(),
..Default::default()
};
add_item(&path, &Anchor::Root, "cli_added", &item, true).expect("replace");
let post = std::fs::read_to_string(&path).unwrap();
for c in [
"# Top-level workload comment",
"# Multi-line",
"# inline comment on cycles",
"# Comment between blocks",
"# phase-block comment",
] {
assert!(post.contains(c), "missing comment {c:?} in:\n{post}");
}
let unrelated = "phases:\n # phase-block comment\n setup:\n ops:\n step: noop\n";
assert!(
post.contains(unrelated),
"phases block changed; got:\n{post}"
);
}
#[test]
fn quote_styles_outside_edit_range_preserved() {
let source = concat!(
"params:\n",
" a: \"double\"\n",
" b: 'single'\n",
" c: bare\n",
"report:\n",
" cli_added: |\n",
" plot demo\n",
" over cycle\n",
);
let path = fresh_workload("quotes_preserved", source);
let item = ReportItem {
kind: Kind::Plot,
name: "demo".to_string(),
label: Some("X".to_string()),
body: "over cycle".to_string(),
..Default::default()
};
add_item(&path, &Anchor::Root, "cli_added", &item, true).expect("replace");
let post = std::fs::read_to_string(&path).unwrap();
assert!(post.contains("a: \"double\""), "double quotes lost: {post}");
assert!(post.contains("b: 'single'"), "single quotes lost: {post}");
assert!(post.contains("c: bare"), "bare scalar lost: {post}");
}
}