use std::path::PathBuf;
use async_trait::async_trait;
use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, Value};
use super::merge_path::{PathSeg, PathSpec, parse_path_spec, parse_segments, shallowest_matches};
use crate::error::{Error, Result};
use super::{
ActionContext, ActionOutcome, ActionPlan, ApplyMode, OutcomeKind, PlanKind, unified_diff,
};
pub struct MergeToml;
#[async_trait]
impl ApplyMode for MergeToml {
async fn plan(&self, ctx: &ActionContext<'_>) -> Result<ActionPlan> {
let new_body = compute_merged(ctx)?;
match ctx.current_body.as_deref() {
None => Ok(ActionPlan {
kind: PlanKind::Create,
diff: Some(unified_diff("", &new_body, ctx.dst_abs.as_str())),
}),
Some(cur) if cur == new_body => Ok(ActionPlan {
kind: PlanKind::Unchanged,
diff: None,
}),
Some(cur) => Ok(ActionPlan {
kind: PlanKind::Update,
diff: Some(unified_diff(cur, &new_body, ctx.dst_abs.as_str())),
}),
}
}
async fn execute(&self, ctx: &ActionContext<'_>, dry_run: bool) -> Result<ActionOutcome> {
let new_body = compute_merged(ctx)?;
let unchanged = ctx.current_body.as_deref() == Some(new_body.as_str());
if unchanged {
return Ok(ActionOutcome {
kind: OutcomeKind::Unchanged,
decision: None,
diff: None,
error: None,
});
}
let diff = unified_diff(
ctx.current_body.as_deref().unwrap_or(""),
&new_body,
ctx.dst_abs.as_str(),
);
if dry_run {
return Ok(ActionOutcome {
kind: OutcomeKind::Skipped,
decision: None,
diff: Some(diff),
error: None,
});
}
if let Some(parent) = ctx.dst_abs.parent() {
tokio::fs::create_dir_all(parent.as_std_path())
.await
.map_err(|e| Error::io_at(parent.as_std_path(), e))?;
}
tokio::fs::write(ctx.dst_abs.as_std_path(), &new_body)
.await
.map_err(|e| Error::io_at(ctx.dst_abs.as_std_path(), e))?;
Ok(ActionOutcome {
kind: OutcomeKind::Wrote,
decision: None,
diff: Some(diff),
error: None,
})
}
}
fn compute_merged(ctx: &ActionContext<'_>) -> Result<String> {
let paths = require_paths(ctx)?;
let existing = match ctx.current_body.as_deref() {
None => return Ok(ctx.rendered_body.clone()),
Some(s) => s,
};
let mut existing_doc: DocumentMut = existing
.parse()
.map_err(|e| Error::Merge(format!("merge-toml: parsing existing {}: {e}", ctx.dst_abs)))?;
let incoming_doc: DocumentMut = ctx.rendered_body.parse().map_err(|e| {
Error::Merge(format!(
"merge-toml: parsing incoming for {}: {e}",
ctx.dst_abs
))
})?;
let mut incoming_paths: Option<Vec<String>> = None;
for path_str in paths {
match parse_path_spec(path_str)? {
PathSpec::Literal(lit) => {
copy_one_path(&mut existing_doc, &incoming_doc, &lit)?;
}
PathSpec::Regex(re) => {
let collected = incoming_paths.get_or_insert_with(|| {
let mut out = Vec::new();
collect_dotted_paths(incoming_doc.as_item(), "", &mut out);
out
});
let to_copy = shallowest_matches(collected, &re);
for p in &to_copy {
copy_one_path(&mut existing_doc, &incoming_doc, p)?;
}
}
}
}
Ok(existing_doc.to_string())
}
fn copy_one_path(
existing_doc: &mut DocumentMut,
incoming_doc: &DocumentMut,
path_str: &str,
) -> Result<()> {
let segments =
parse_segments(path_str).map_err(|e| Error::Merge(format!("merge-toml: {e}")))?;
if segments.is_empty() {
return Ok(());
}
if let Some(value) = item_at_path(incoming_doc.as_item(), &segments) {
let already_matches = item_at_path(existing_doc.as_item(), &segments)
.as_ref()
.is_some_and(|cur| items_equivalent(cur, &value));
if !already_matches {
set_at_path(existing_doc, &segments, value);
}
}
Ok(())
}
fn collect_dotted_paths(item: &Item, prefix: &str, out: &mut Vec<String>) {
match item {
Item::Table(table) => collect_in_table(table, prefix, out),
Item::ArrayOfTables(aot) => {
for (idx, elem) in aot.iter().enumerate() {
let path = format!("{prefix}[{idx}]");
out.push(path.clone());
collect_in_table(elem, &path, out);
}
}
Item::Value(Value::Array(arr)) => {
for (idx, _elem) in arr.iter().enumerate() {
let path = format!("{prefix}[{idx}]");
out.push(path);
}
}
_ => {}
}
}
fn collect_in_table(table: &Table, prefix: &str, out: &mut Vec<String>) {
for (key, value) in table.iter() {
let path = if prefix.is_empty() {
key.to_string()
} else {
format!("{prefix}.{key}")
};
out.push(path.clone());
collect_dotted_paths(value, &path, out);
}
}
fn items_equivalent(a: &Item, b: &Item) -> bool {
fn canon(item: &Item) -> String {
let mut doc = DocumentMut::new();
doc.as_table_mut().insert("v", item.clone());
doc.to_string()
}
canon(a) == canon(b)
}
fn item_at_path(item: &Item, path: &[PathSeg]) -> Option<Item> {
if path.is_empty() {
return Some(item.clone());
}
item_at_table_path(item.as_table()?, path)
}
fn item_at_table_path(table: &Table, path: &[PathSeg]) -> Option<Item> {
let (head, rest) = path.split_first().expect("caller checks non-empty");
match head {
PathSeg::Key(k) => {
let next = table.get(k)?;
if rest.is_empty() {
return Some(next.clone());
}
item_at_table_path(next.as_table()?, rest)
}
PathSeg::KeyIndex(k, i) => {
let entry = table.get(k)?;
if let Some(aot) = entry.as_array_of_tables() {
let elem = aot.get(*i)?;
if rest.is_empty() {
return Some(Item::Table(elem.clone()));
}
return item_at_table_path(elem, rest);
}
if let Some(arr) = entry.as_array() {
let v = arr.get(*i)?;
if rest.is_empty() {
return Some(Item::Value(v.clone()));
}
return None;
}
None
}
}
}
fn set_at_path(doc: &mut DocumentMut, path: &[PathSeg], value: Item) {
set_in_table(doc.as_table_mut(), path, value);
}
fn set_in_table(table: &mut Table, path: &[PathSeg], value: Item) {
let Some((head, rest)) = path.split_first() else {
return;
};
let is_leaf = rest.is_empty();
match head {
PathSeg::Key(k) => {
if is_leaf {
if let Some(existing) = table.get_mut(k) {
*existing = value;
} else {
table.insert(k, value);
}
} else {
let entry = table.entry(k).or_insert_with(|| Item::Table(Table::new()));
let Some(next) = entry.as_table_mut() else {
return; };
set_in_table(next, rest, value);
}
}
PathSeg::KeyIndex(k, i) => {
if is_leaf {
match value {
Item::Table(value_table) => {
let Some(elem) = ensure_aot_element(table, k, *i) else {
return;
};
*elem = value_table;
}
Item::Value(value_v) => {
let Some(elem) = ensure_array_element(table, k, *i) else {
return;
};
*elem = value_v;
}
_ => {}
}
} else {
let Some(elem) = ensure_aot_element(table, k, *i) else {
return;
};
set_in_table(elem, rest, value);
}
}
}
}
fn ensure_aot_element<'a>(table: &'a mut Table, key: &str, idx: usize) -> Option<&'a mut Table> {
match table.entry(key) {
toml_edit::Entry::Vacant(slot) => {
if idx != 0 {
return None;
}
let mut aot = ArrayOfTables::new();
aot.push(Table::new());
slot.insert(Item::ArrayOfTables(aot))
.as_array_of_tables_mut()?
.get_mut(0)
}
toml_edit::Entry::Occupied(slot) => {
let aot = slot.into_mut().as_array_of_tables_mut()?;
if aot.is_empty() {
if idx != 0 {
return None;
}
aot.push(Table::new());
return aot.get_mut(0);
}
if idx >= aot.len() {
return None;
}
aot.get_mut(idx)
}
}
}
fn ensure_array_element<'a>(table: &'a mut Table, key: &str, idx: usize) -> Option<&'a mut Value> {
match table.entry(key) {
toml_edit::Entry::Vacant(slot) => {
if idx != 0 {
return None;
}
let mut arr = Array::new();
arr.push(Value::from(0i64));
slot.insert(Item::Value(Value::Array(arr)))
.as_array_mut()?
.get_mut(0)
}
toml_edit::Entry::Occupied(slot) => {
let arr = slot.into_mut().as_array_mut()?;
if arr.is_empty() {
if idx != 0 {
return None;
}
arr.push(Value::from(0i64));
return arr.get_mut(0);
}
if idx >= arr.len() {
return None;
}
arr.get_mut(idx)
}
}
}
fn require_paths<'a>(ctx: &'a ActionContext<'_>) -> Result<&'a Vec<String>> {
if ctx.spec.paths.is_empty() {
return Err(Error::manifest(
PathBuf::from(&ctx.template.source_spec),
format!(
"how=\"merge-toml\" requires `paths = [...]` in `[[file]]` for {}",
ctx.spec.src
),
));
}
Ok(&ctx.spec.paths)
}
#[cfg(test)]
mod tests {
use super::*;
fn merge(existing: Option<&str>, incoming: &str, paths: &[&str]) -> String {
let paths_owned: Vec<String> = paths.iter().map(|s| s.to_string()).collect();
match existing {
None => incoming.to_string(),
Some(existing) => {
let mut existing_doc: DocumentMut = existing.parse().unwrap();
let incoming_doc: DocumentMut = incoming.parse().unwrap();
let mut incoming_paths: Option<Vec<String>> = None;
for path_str in &paths_owned {
match parse_path_spec(path_str).unwrap() {
PathSpec::Literal(lit) => {
copy_one_path(&mut existing_doc, &incoming_doc, &lit).unwrap();
}
PathSpec::Regex(re) => {
let collected = incoming_paths.get_or_insert_with(|| {
let mut out = Vec::new();
collect_dotted_paths(incoming_doc.as_item(), "", &mut out);
out
});
for p in &shallowest_matches(collected, &re) {
copy_one_path(&mut existing_doc, &incoming_doc, p).unwrap();
}
}
}
}
existing_doc.to_string()
}
}
}
#[test]
fn merge_replaces_only_listed_path() {
let existing = "\
# header comment
[package]
name = \"demo\"
[dependencies]
serde = \"1.0.180\" # old version
clap = \"4.5\" # don't touch me
";
let incoming = "\
[package]
name = \"demo\"
[dependencies]
serde = \"1.0.220\"
";
let merged = merge(Some(existing), incoming, &["dependencies.serde"]);
assert!(
merged.contains("serde = \"1.0.220\""),
"serde should be updated: {merged}"
);
assert!(
merged.contains("clap = \"4.5\" # don't touch me"),
"clap line + trailing comment must be preserved: {merged}"
);
assert!(merged.starts_with("# header comment\n"));
}
#[test]
fn merge_creates_intermediate_tables() {
let existing = "[package]\nname = \"demo\"\n";
let incoming = "\
[package]
name = \"demo\"
[dependencies]
serde = \"1\"
";
let merged = merge(Some(existing), incoming, &["dependencies.serde"]);
assert!(merged.contains("[dependencies]"));
assert!(merged.contains("serde = \"1\""));
assert!(merged.contains("name = \"demo\""));
}
#[test]
fn merge_skips_path_missing_from_incoming() {
let existing = "[deps]\nserde = \"1\"\n";
let incoming = "[deps]\nclap = \"4\"\n"; let merged = merge(Some(existing), incoming, &["deps.serde"]);
assert!(merged.contains("serde = \"1\""));
assert!(!merged.contains("clap"));
}
#[test]
fn merge_does_not_touch_unlisted_paths() {
let existing = "\
[a]
keep = 1
[b]
also_keep = 2
";
let incoming = "\
[a]
keep = 99
[b]
also_keep = 88
";
let merged = merge(Some(existing), incoming, &["a.keep"]);
assert!(merged.contains("keep = 99")); assert!(merged.contains("also_keep = 2")); }
#[test]
fn merge_creates_full_file_when_dst_absent() {
let incoming = "[package]\nname = \"x\"\n";
let merged = merge(None, incoming, &["package.name"]);
assert_eq!(merged, incoming);
}
#[test]
fn merge_is_idempotent_with_interleaved_consumer_keys() {
let existing = "\
[tasks.check]
deps = [\"fmt-check\", \"clippy\", \"test\"]
[tasks.clippy-none]
# consumer-specific task, MUST stay between clippy and test
desc = \"clippy with --no-default-features\"
[tasks.clippy]
args = [\"clippy\", \"--all-targets\"]
[tasks.test-all]
# another consumer task interleaved deeper in
desc = \"run all tests\"
[tasks.test]
args = [\"test\", \"--all-targets\"]
";
let incoming = "\
[tasks.check]
deps = [\"fmt-check\", \"clippy\", \"test\"]
[tasks.clippy]
args = [\"clippy\", \"--all-targets\", \"--\", \"-D\", \"warnings\"]
[tasks.test]
args = [\"test\", \"--all-targets\"]
";
let paths = &["tasks.check", "tasks.clippy", "tasks.test"];
let first = merge(Some(existing), incoming, paths);
let second = merge(Some(&first), incoming, paths);
assert_eq!(
first, second,
"merge must be idempotent across re-applies — drift\n\
on a no-op merge is yukimemi/kata#34.\n\
first:\n{first}\nsecond:\n{second}",
);
assert!(first.contains("clippy-none"), "consumer task lost: {first}");
assert!(first.contains("test-all"), "consumer task lost: {first}");
}
#[test]
fn merge_refuses_to_clobber_non_table_intermediate() {
let existing = "package = \"as-a-string\"\n";
let incoming = "[package]\nname = \"new\"\n";
let merged = merge(Some(existing), incoming, &["package.name"]);
assert!(
merged.contains("package = \"as-a-string\""),
"non-table intermediate must NOT be clobbered: {merged}"
);
assert!(
!merged.contains("[package]") && !merged.contains("name = \"new\""),
"no fresh [package] table should appear: {merged}"
);
}
#[test]
fn regex_path_sweeps_all_tasks_subkeys() {
let existing = "\
[tasks.default]
deps = [\"old\"]
[tasks.test]
args = [\"old-args\"]
";
let incoming = "\
[tasks.default]
deps = [\"check\"]
[tasks.test]
args = [\"test\", \"--all-targets\"]
[tasks.test-doc]
args = [\"test\", \"--doc\"]
";
let merged = merge(Some(existing), incoming, &[r"//^tasks\..+$//"]);
assert!(
merged.contains("deps = [\"check\"]"),
"regex must update tasks.default: {merged}"
);
assert!(
merged.contains("test-doc") && merged.contains("--doc"),
"regex must also pull in tasks.test-doc (new sub-key): {merged}"
);
}
#[test]
fn regex_and_literal_paths_compose() {
let existing = "\
[a]
keep_a = 1
[b]
keep_b = 2
";
let incoming = "\
[a]
keep_a = 99
[b]
keep_b = 88
nested = \"new\"
";
let merged = merge(Some(existing), incoming, &["a.keep_a", r"//^b\..+$//"]);
assert!(merged.contains("keep_a = 99"), "literal: {merged}");
assert!(merged.contains("keep_b = 88"), "regex hit keep_b: {merged}");
assert!(
merged.contains("nested = \"new\""),
"regex hit nested: {merged}"
);
}
#[test]
fn merge_replaces_only_index_zero_of_array_of_tables() {
let existing = "\
[[hooks.post_create]]
cmd = \"cargo make on-add\"
[[hooks.post_create]]
cmd = \"bun install --cwd crates/kanade-backend/web\"
";
let incoming = "\
[[hooks.post_create]]
cmd = \"cargo make on-add --updated\"
";
let merged = merge(Some(existing), incoming, &["hooks.post_create[0]"]);
assert!(
merged.contains("cargo make on-add --updated"),
"element 0 must be updated: {merged}"
);
assert!(
merged.contains("bun install --cwd crates/kanade-backend/web"),
"element 1 (consumer's) must survive: {merged}"
);
}
#[test]
fn merge_bootstraps_array_of_tables_when_missing() {
let existing = "[project]\nname = \"x\"\n";
let incoming = "\
[[hooks.post_create]]
cmd = \"cargo make on-add\"
";
let merged = merge(Some(existing), incoming, &["hooks.post_create[0]"]);
assert!(
merged.contains("[[hooks.post_create]]"),
"missing array must be bootstrapped: {merged}"
);
assert!(
merged.contains("cmd = \"cargo make on-add\""),
"bootstrapped element must carry the value: {merged}"
);
assert!(merged.contains("name = \"x\""));
}
#[test]
fn merge_skips_out_of_range_index_on_shorter_array() {
let existing = "\
[[hooks.post_create]]
cmd = \"keep\"
";
let incoming = "\
[[hooks.post_create]]
cmd = \"first\"
[[hooks.post_create]]
cmd = \"second\"
";
let merged = merge(Some(existing), incoming, &["hooks.post_create[1]"]);
assert!(merged.contains("cmd = \"keep\""));
assert!(
!merged.contains("cmd = \"second\""),
"must not pad / append: {merged}"
);
}
#[test]
fn merge_skips_index_zero_against_non_array_intermediate() {
let existing = "\
[hooks]
post_create = \"not-an-array\"
";
let incoming = "\
[[hooks.post_create]]
cmd = \"cargo make on-add\"
";
let merged = merge(Some(existing), incoming, &["hooks.post_create[0]"]);
assert!(
merged.contains("post_create = \"not-an-array\""),
"non-array intermediate must NOT be clobbered: {merged}"
);
assert!(
!merged.contains("[[hooks.post_create]]"),
"no array form should appear: {merged}"
);
}
#[test]
fn merge_can_address_field_inside_array_element() {
let existing = "\
[[hooks.post_create]]
cmd = \"old\"
cwd = \"keep\"
[[hooks.post_create]]
cmd = \"consumer\"
";
let incoming = "\
[[hooks.post_create]]
cmd = \"new\"
cwd = \"replaced\"
";
let merged = merge(Some(existing), incoming, &["hooks.post_create[0].cmd"]);
assert!(merged.contains("cmd = \"new\""), "cmd updated: {merged}");
assert!(
merged.contains("cwd = \"keep\""),
"sibling key inside element 0 preserved: {merged}"
);
assert!(
merged.contains("cmd = \"consumer\""),
"element 1 preserved: {merged}"
);
}
#[test]
fn merge_array_index_path_is_idempotent() {
let existing = "\
[[hooks.post_create]]
cmd = \"cargo make on-add\"
[[hooks.post_create]]
cmd = \"bun install\"
";
let incoming = "\
[[hooks.post_create]]
cmd = \"cargo make on-add\"
";
let first = merge(Some(existing), incoming, &["hooks.post_create[0]"]);
let second = merge(Some(&first), incoming, &["hooks.post_create[0]"]);
assert_eq!(
first, second,
"merge must be idempotent on array-index paths"
);
}
#[test]
fn regex_can_target_specific_array_element() {
let existing = "\
[[hooks.post_create]]
cmd = \"old\"
[[hooks.post_create]]
cmd = \"consumer\"
";
let incoming = "\
[[hooks.post_create]]
cmd = \"new\"
";
let merged = merge(
Some(existing),
incoming,
&[r"//^hooks\.post_create\[0\]$//"],
);
assert!(merged.contains("cmd = \"new\""));
assert!(
merged.contains("cmd = \"consumer\""),
"consumer element survives regex: {merged}"
);
}
#[test]
fn collect_dotted_paths_emits_array_index_forms() {
let doc: DocumentMut = "\
[[hooks.post_create]]
cmd = \"a\"
[[hooks.post_create]]
cmd = \"b\"
"
.parse()
.unwrap();
let mut paths = Vec::new();
collect_dotted_paths(doc.as_item(), "", &mut paths);
assert!(
paths.iter().any(|p| p == "hooks.post_create"),
"parent AoT path present: {paths:?}"
);
assert!(
paths.iter().any(|p| p == "hooks.post_create[0]"),
"element 0 path present: {paths:?}"
);
assert!(
paths.iter().any(|p| p == "hooks.post_create[1]"),
"element 1 path present: {paths:?}"
);
assert!(
paths.iter().any(|p| p == "hooks.post_create[0].cmd"),
"inside-element path present: {paths:?}"
);
}
#[test]
fn merge_replaces_only_index_zero_of_inline_array() {
let existing = "\
[tasks.on-add]
dependencies = [\"cargo make fmt\", \"bun install\"]
";
let incoming = "\
[tasks.on-add]
dependencies = [\"cargo make fmt-v2\"]
";
let merged = merge(Some(existing), incoming, &["tasks.on-add.dependencies[0]"]);
assert!(
merged.contains("cargo make fmt-v2"),
"element 0 must be updated: {merged}"
);
assert!(
merged.contains("bun install"),
"consumer's element 1 must survive: {merged}"
);
}
#[test]
fn merge_bootstraps_inline_array_when_missing() {
let existing = "[tasks.on-add]\n";
let incoming = "\
[tasks.on-add]
dependencies = [\"cargo make fmt\"]
";
let merged = merge(Some(existing), incoming, &["tasks.on-add.dependencies[0]"]);
assert!(
merged.contains("dependencies = [\"cargo make fmt\"]")
|| merged.contains("dependencies = [\"cargo make fmt\",]"),
"missing inline array must be bootstrapped: {merged}"
);
}
#[test]
fn merge_skips_out_of_range_index_on_shorter_inline_array() {
let existing = "deps = [\"keep\"]\n";
let incoming = "deps = [\"first\", \"second\"]\n";
let merged = merge(Some(existing), incoming, &["deps[1]"]);
assert!(merged.contains("\"keep\""));
assert!(
!merged.contains("\"second\""),
"must not pad / append: {merged}"
);
}
#[test]
fn merge_refuses_to_clobber_non_array_at_inline_index_path() {
let existing = "tags = \"not-an-array\"\n";
let incoming = "tags = [\"first\"]\n";
let merged = merge(Some(existing), incoming, &["tags[0]"]);
assert!(
merged.contains("tags = \"not-an-array\""),
"non-array intermediate must NOT be clobbered: {merged}"
);
}
#[test]
fn merge_can_replace_string_element_of_inline_array() {
let existing = "tags = [\"old\", \"keep\"]\n";
let incoming = "tags = [\"new\"]\n";
let merged = merge(Some(existing), incoming, &["tags[0]"]);
assert!(merged.contains("\"new\""), "idx 0 updated: {merged}");
assert!(merged.contains("\"keep\""), "idx 1 preserved: {merged}");
}
#[test]
fn merge_inline_array_index_is_idempotent() {
let existing = "\
[tasks.on-add]
dependencies = [\"cargo make fmt\", \"bun install\"]
";
let incoming = "\
[tasks.on-add]
dependencies = [\"cargo make fmt\"]
";
let first = merge(Some(existing), incoming, &["tasks.on-add.dependencies[0]"]);
let second = merge(Some(&first), incoming, &["tasks.on-add.dependencies[0]"]);
assert_eq!(first, second, "inline-array index merge must be idempotent");
}
#[test]
fn merge_refuses_when_existing_is_aot_but_incoming_is_inline_array() {
let existing = "\
[[deps]]
name = \"a\"
";
let incoming = "deps = [\"new\"]\n";
let merged = merge(Some(existing), incoming, &["deps[0]"]);
assert!(
merged.contains("[[deps]]") && merged.contains("name = \"a\""),
"AoT existing must survive when incoming is inline array: {merged}"
);
}
#[test]
fn regex_can_target_specific_inline_array_element() {
let existing = "deps = [\"old\", \"consumer\"]\n";
let incoming = "deps = [\"new\"]\n";
let merged = merge(Some(existing), incoming, &[r"//^deps\[0\]$//"]);
assert!(merged.contains("\"new\""), "regex hit idx 0: {merged}");
assert!(
merged.contains("\"consumer\""),
"regex left idx 1 alone: {merged}"
);
}
#[test]
fn collect_dotted_paths_emits_inline_array_index_forms() {
let doc: DocumentMut = "\
[tasks.on-add]
dependencies = [\"a\", \"b\"]
"
.parse()
.unwrap();
let mut paths = Vec::new();
collect_dotted_paths(doc.as_item(), "", &mut paths);
assert!(
paths.iter().any(|p| p == "tasks.on-add.dependencies"),
"parent inline-array path present: {paths:?}"
);
assert!(
paths.iter().any(|p| p == "tasks.on-add.dependencies[0]"),
"element 0 path present: {paths:?}"
);
assert!(
paths.iter().any(|p| p == "tasks.on-add.dependencies[1]"),
"element 1 path present: {paths:?}"
);
}
#[test]
fn regex_skips_paths_not_in_incoming() {
let existing = "\
[tasks.only_in_existing]
note = \"keep\"
";
let incoming = "\
[tasks.only_in_incoming]
note = \"add\"
";
let merged = merge(Some(existing), incoming, &[r"//^tasks\..+$//"]);
assert!(
merged.contains("only_in_existing") && merged.contains("note = \"keep\""),
"existing-only key must survive regex sweep: {merged}"
);
assert!(
merged.contains("only_in_incoming") && merged.contains("note = \"add\""),
"incoming-only key (matching regex) must be added: {merged}"
);
}
}