diff --git a/crates/mise-util/src/structured_merge.rs b/crates/mise-util/src/structured_merge.rs index cff7d128e7f..e4ded964d11 100644 --- a/crates/mise-util/src/structured_merge.rs +++ b/crates/mise-util/src/structured_merge.rs @@ -114,6 +114,83 @@ fn parse(format: Format, text: &str, what: &str) -> Result { }) } +/// Dotted paths that `a` and `b` both set, to different values. Two sources +/// that agree on a key, or that set different keys of the same table, do not +/// conflict. +pub fn conflicts(format: Format, a: &str, b: &str) -> Result> { + let mut paths = vec![]; + match (parse(format, a, "source")?, parse(format, b, "source")?) { + (Doc::Json(a), Doc::Json(b)) => json_conflicts(&a, &b, "", &mut paths), + (Doc::Toml(a), Doc::Toml(b)) => toml_conflicts(&a, &b, "", &mut paths), + (Doc::Yaml(a), Doc::Yaml(b)) => yaml_conflicts(&a, &b, "", &mut paths), + _ => unreachable!("both sides are parsed as the same format"), + } + Ok(paths) +} + +fn child_path(prefix: &str, key: &str) -> String { + if prefix.is_empty() { + key.to_string() + } else { + format!("{prefix}.{key}") + } +} + +fn json_conflicts(a: &Value, b: &Value, prefix: &str, paths: &mut Vec) { + let (Value::Object(a), Value::Object(b)) = (a, b) else { + return; + }; + for (key, av) in a { + let Some(bv) = b.get(key) else { continue }; + let path = child_path(prefix, key); + if av.is_object() && bv.is_object() { + json_conflicts(av, bv, &path, paths); + } else if av != bv { + paths.push(path); + } + } +} + +fn toml_conflicts(a: &toml::Table, b: &toml::Table, prefix: &str, paths: &mut Vec) { + for (key, av) in a { + let Some(bv) = b.get(key) else { continue }; + let path = child_path(prefix, key); + match (av, bv) { + (toml::Value::Table(av), toml::Value::Table(bv)) => { + toml_conflicts(av, bv, &path, paths); + } + _ if av != bv => paths.push(path), + _ => {} + } + } +} + +fn yaml_conflicts( + a: &serde_yaml::Mapping, + b: &serde_yaml::Mapping, + prefix: &str, + paths: &mut Vec, +) { + for (key, av) in a { + let Some(bv) = b.get(key) else { continue }; + let name = match key { + serde_yaml::Value::String(name) => name.clone(), + other => serde_yaml::to_string(other) + .unwrap_or_default() + .trim() + .to_string(), + }; + let path = child_path(prefix, &name); + match (av, bv) { + (serde_yaml::Value::Mapping(av), serde_yaml::Value::Mapping(bv)) => { + yaml_conflicts(av, bv, &path, paths); + } + _ if av != bv => paths.push(path), + _ => {} + } + } +} + fn is_comments_only(text: &str) -> bool { text.lines() .all(|l| l.trim().is_empty() || l.trim_start().starts_with('#')) @@ -464,6 +541,30 @@ path = \"/Applications/X.app\" assert!(!contains(Format::Yaml, target, "env:\n 2: three\n").unwrap()); } + #[test] + fn sources_conflict_only_on_different_values_for_one_key() { + let conflicts = |a, b| conflicts(Format::Toml, a, b).unwrap(); + assert_eq!( + conflicts("m = 1\n[t]\nx = 1\ny = 2\n", "m = 2\n[t]\nx = 9\ny = 2\n"), + vec!["m".to_string(), "t.x".to_string()] + ); + assert!(conflicts("m = 1\n[t]\nx = 1\n", "m = 1\n[t]\ny = 2\n").is_empty()); + // a table against a scalar is a conflict too + assert_eq!(conflicts("t = 1\n", "[t]\nx = 1\n"), vec!["t".to_string()]); + } + + #[test] + fn conflicts_are_found_in_every_format() { + assert_eq!( + conflicts(Format::Json, r#"{"a": {"b": 1}}"#, r#"{"a": {"b": 2}}"#).unwrap(), + vec!["a.b".to_string()] + ); + assert_eq!( + conflicts(Format::Yaml, "a:\n b: 1\n1: x\n", "a:\n b: 2\n1: y\n").unwrap(), + vec!["a.b".to_string(), "1".to_string()] + ); + } + #[test] fn merging_twice_changes_nothing() { for (format, target, source) in [ diff --git a/docs/dotfiles.md b/docs/dotfiles.md index 1f4f21eff86..6a0ec6dc845 100644 --- a/docs/dotfiles.md +++ b/docs/dotfiles.md @@ -825,9 +825,9 @@ alias la='ls -la' "~/.gitconfig/identity" = { source = "snippets/git-identity.tmpl", template = "tera" } ``` -For edit entries, `source` is paired with `template = "tera"` to make the -entry unambiguously an edit. A table with only `source` is a whole-file -entry using `dotfiles.default_mode`. +For edit entries, `source` is paired with `template = "tera"` or +[`merge = true`](#merge) to make the entry unambiguously an edit. A table with +only `source` is a whole-file entry using `dotfiles.default_mode`. A `block` is delimited by marker comments in the target file, named by the entry's id: @@ -854,6 +854,60 @@ Running apply again leaves an existing match wherever it is. Other bytes, including line endings, stay unchanged. The value must be a single line; use a block for multi-line content. +### Setting some keys of a config file {#merge} + +Some applications write their own state into the same file that holds the +settings you care about: Codex rewrites `~/.codex/config.toml`, Claude Code +rewrites `~/.claude/settings.json`, and other tools re-serialize a YAML file +on every change. A `symlink` or `copy` entry fights them for the whole file, +and a `block` can't survive an application that rewrites the file. + +A `merge` entry owns only the keys in its source. Everything else in the file +stays the application's: + +```toml +[dotfiles] +"~/.codex/config.toml/shared" = { source = "codex/shared.toml", merge = true } +"~/.claude/settings.json/shared" = { source = "claude/shared.json", merge = true } +"~/.omp/agent/config.yml/shared" = { source = "omp/shared.yml", merge = true } +``` + +The source holds the keys you want in the same format as the target, which +mise infers from the target's `.json`, `.toml`, `.yaml`, or `.yml` extension. +With `codex/shared.toml` containing: + +```toml +model = "gpt-5" + +[tui] +theme = "light" +``` + +applying sets `model` and `tui.theme` and leaves the rest of +`~/.codex/config.toml` as it is, including `[plugins.*]` tables and comments +the application wrote. A missing target is created from the source. + +- A table or object that exists on both sides merges recursively. Any other + value, including an array, is replaced by the source's value. +- Keys that only the target has are never changed, and keys you remove from + the source are not removed from the target. +- `mise dot status` and `mise dot diff` look only at the keys in the source, + so keys the application adds never show up as drift. +- TOML and YAML are edited in place, so comments, key order, and the + formatting of untouched keys stay. JSON has no comments; mise keeps key + order and the file's indentation, and rewrites the file only when an owned + key differs. A target that is not valid JSON, TOML, or YAML (including JSON + with comments) is reported and never overwritten. +- Two merge entries for one file that set the same key to different values are + refused instead of fighting over it. An entry with `template = "tera"` is + rendered only when it is applied, so a conflict with one is found when both + are applied in the same run, not when one is applied alone through a target + filter. mise does not compare a merge with a + `block` or `line` edit of the same file, so don't have both own one key. +- `template = "tera"` renders the source first, like other edit entries. +- `mise dot unapply` leaves merged keys in place, because the application may + have changed them since. + ## How configuration is applied {#semantics} - Entries merge across the [config hierarchy](/configuration.html). diff --git a/e2e/cli/test_dotfiles_merge_edits b/e2e/cli/test_dotfiles_merge_edits new file mode 100644 index 00000000000..125d23e174d --- /dev/null +++ b/e2e/cli/test_dotfiles_merge_edits @@ -0,0 +1,303 @@ +#!/usr/bin/env bash + +# merge entries own some keys of a JSON, TOML, or YAML file that an +# application also writes; every other key, comment, and the key order stay +mkdir -p shared +cat <<'EOF' >shared/codex.toml +model = "gpt-5" + +[tui] +theme = "light" +EOF +cat <<'EOF' >shared/claude.json +{"model": "opus", "permissions": {"allow": ["Bash(ls)"]}} +EOF +cat <<'EOF' >shared/omp.yml +theme: light +tools: + bash: true +EOF +cat <<'EOF' >mise.toml +[dotfiles] +"~/.codex/config.toml/shared" = { source = "shared/codex.toml", merge = true } +"~/.claude/settings.json/shared" = { source = "shared/claude.json", merge = true } +"~/.omp/config.yml/shared" = { source = "shared/omp.yml", merge = true } +EOF + +# nothing exists yet, so everything is missing +assert_contains "mise dotfiles status" "merge:shared" +assert_contains "mise dotfiles status" "missing" +assert_fail "mise dotfiles status --missing" + +# a dry run lists the work and writes nothing +assert_contains "mise dotfiles apply --dry-run" "merge:shared" +assert_fail "cat ~/.codex/config.toml" + +# applying creates missing files from the source +assert_succeed "mise dotfiles apply --yes" +assert_contains "cat ~/.codex/config.toml" 'model = "gpt-5"' +assert_contains "cat ~/.claude/settings.json" '"model": "opus"' +assert_contains "cat ~/.omp/config.yml" "theme: light" + +# the applications write their own keys, comments included +mkdir -p ~/.codex ~/.claude ~/.omp +cat <<'EOF' >~/.codex/config.toml +# written by the desktop app +notify = ["a"] +model = "gpt-4" + +[tui] +theme = "dark" # app picked this + +[plugins.foo] +path = "/Applications/X.app" +EOF +cat <<'EOF' >~/.claude/settings.json +{ + "zzz": 1, + "model": "sonnet", + "permissions": {"deny": ["Bash(rm)"]} +} +EOF +cat <<'EOF' >~/.omp/config.yml +# omp keeps this note +theme: dark # toggled in the UI +modes: + fast: true +tools: + bash: false +EOF + +# drift is reported for owned keys only +assert_contains "mise dotfiles status" "differs" +assert_contains "mise dotfiles diff" '-model = "gpt-4"' +assert_contains "mise dotfiles diff" '+model = "gpt-5"' +assert_not_contains "mise dotfiles diff" '-notify' + +assert_succeed "mise dotfiles apply --yes" + +# owned keys are set; everything else is exactly where the app left it +cat <<'EOF' >expected-codex.toml +# written by the desktop app +notify = ["a"] +model = "gpt-5" + +[tui] +theme = "light" # app picked this + +[plugins.foo] +path = "/Applications/X.app" +EOF +assert_succeed "cmp expected-codex.toml ~/.codex/config.toml" +cat <<'EOF' >expected-omp.yml +# omp keeps this note +theme: light # toggled in the UI +modes: + fast: true +tools: + bash: true +EOF +assert_succeed "cmp expected-omp.yml ~/.omp/config.yml" +assert "jq -r .zzz ~/.claude/settings.json" "1" +assert "jq -r .model ~/.claude/settings.json" "opus" +assert "jq -c .permissions ~/.claude/settings.json" '{"deny":["Bash(rm)"],"allow":["Bash(ls)"]}' +assert "head -2 ~/.claude/settings.json | tail -1" ' "zzz": 1,' + +# in sync now, and an application writing a new key does not cause drift +assert_succeed "mise dotfiles status --missing" +echo 'extra = true' >>~/.codex/config.toml +assert_succeed "mise dotfiles apply --yes" +assert_contains "cat ~/.codex/config.toml" "extra = true" +assert_contains "mise dotfiles apply --dry-run 2>&1" "all edits are applied" + +# unapply leaves merged keys alone +assert_succeed "mise dotfiles unapply --yes" +assert_contains "cat ~/.codex/config.toml" 'model = "gpt-5"' + +# unusable entries are refused instead of guessed at +cat <<'EOF' >mise.toml +[dotfiles] +"~/notes.txt/shared" = { source = "shared/codex.toml", merge = true } +EOF +assert_contains "mise dotfiles status 2>&1" "merge needs a .json, .toml, .yaml, or .yml target" + +# a target that does not parse is reported, never overwritten +cat <<'EOF' >mise.toml +[dotfiles] +"~/broken.json/shared" = { source = "shared/claude.json", merge = true } +EOF +echo '{// not json' >~/broken.json +assert_fail "mise dotfiles apply --yes" +assert "cat ~/broken.json" "{// not json" + +# a source that does not parse is refused before anything is written, even +# when its target does not exist yet +mkdir -p ~/early +echo '{// not json' >shared/bad.json +cat <<'EOF' >mise.toml +[dotfiles] +"~/early/first.toml/shared" = { source = "shared/codex.toml", merge = true } +"~/early/second.json/shared" = { source = "shared/bad.json", merge = true } +EOF +assert_fail_contains "mise dotfiles apply --yes" "invalid merge source" +assert_fail "cat ~/early/first.toml" + +# two entries that set one key to different values are refused, not flipped +cat <<'EOF' >shared/other.toml +model = "gpt-4" +EOF +cat <<'EOF' >mise.toml +[dotfiles] +"~/conflict.toml/a" = { source = "shared/codex.toml", merge = true } +"~/conflict.toml/b" = { source = "shared/other.toml", merge = true } +EOF +assert_fail_contains "mise dotfiles apply --yes" "set different values for model" +assert_fail "cat ~/conflict.toml" + +# a trailing newline in a YAML block scalar is part of the value +printf 'notes: |\n line one\n line two\n' >shared/notes.yml +cat <<'EOF' >mise.toml +[dotfiles] +"~/notes.yml/shared" = { source = "shared/notes.yml", merge = true } +EOF +assert_succeed "mise dotfiles apply --yes" +assert_succeed "mise dotfiles status --missing" +assert_contains "mise dotfiles apply --dry-run 2>&1" "all edits are applied" + +# unapply never touches a merge target, even one it could not read +ln -sf ~/notes.yml ~/notes-link.yml +cat <<'EOF' >mise.toml +[dotfiles] +"~/notes-link.yml/shared" = { source = "shared/notes.yml", merge = true } +EOF +assert_succeed "mise dotfiles unapply --yes" + +# the same file under another name (a hard link here, a case variant on +# case-insensitive filesystems) still counts as one target for conflicts +printf 'model = "x"\n' >~/hl.toml +ln ~/hl.toml ~/hl-alias.toml +cat <<'EOF' >mise.toml +[dotfiles] +"~/hl.toml/a" = { source = "shared/codex.toml", merge = true } +"~/hl-alias.toml/b" = { source = "shared/other.toml", merge = true } +EOF +assert_fail_contains "mise dotfiles apply --yes" "set different values for model" +assert "cat ~/hl.toml" 'model = "x"' + +# applying one of two conflicting entries through a target filter still sees +# the other one +cat <<'EOF' >mise.toml +[dotfiles] +"~/filtered.toml/a" = { source = "shared/codex.toml", merge = true } +"~/filtered.toml/b" = { source = "shared/other.toml", merge = true } +EOF +assert_fail_contains "mise dotfiles apply ~/filtered.toml/a --yes" "set different values for model" +assert_fail "cat ~/filtered.toml" + +# two siblings that disagree do not block applying a third entry that this run +# is not conflicting with +cat <<'EOF' >shared/third.toml +other = 1 +EOF +cat <<'EOF' >mise.toml +[dotfiles] +"~/trio.toml/a" = { source = "shared/codex.toml", merge = true } +"~/trio.toml/b" = { source = "shared/other.toml", merge = true } +"~/trio.toml/c" = { source = "shared/third.toml", merge = true } +EOF +assert_succeed "mise dotfiles apply ~/trio.toml/c --yes" +assert "cat ~/trio.toml" "other = 1" + +# a sibling whose target is blocked is not rendered, so its template's exec() +# does not run +printf 'x = 1\n' >~/real-target.toml +ln -sf ~/real-target.toml ~/linked.toml +cat <<'EOF' >shared/exec.toml.tera +{{ exec(command="touch $HOME/sibling-exec-ran") }}x = 2 +EOF +cat <<'EOF' >mise.toml +[dotfiles] +"~/real-target.toml/a" = { source = "shared/other.toml", merge = true } +"~/linked.toml/b" = { source = "shared/exec.toml.tera", merge = true, template = "tera" } +EOF +rm -f ~/sibling-exec-ran +assert_succeed "mise dotfiles apply ~/real-target.toml/a --yes" +assert_fail "test -e ~/sibling-exec-ran" + +# whole-file keys on a merge entry are refused by name and never make the +# target a directory +cat <<'EOF' >mise.toml +[dotfiles] +"~/withx.json/shared" = { source = "shared/claude.json", merge = true, exclude = [] } +EOF +assert_contains "mise dotfiles status 2>&1" "exclude applies to whole-file entries, not merge edits" +mise dotfiles apply --yes >/dev/null 2>&1 +assert_fail "test -e ~/withx.json" + +# one file merged as two formats (a hard link under a .json and a .yml name) is +# refused whatever the sources hold +printf '{}\n' >~/fmt.json +ln ~/fmt.json ~/fmt.yml +cat <<'EOF' >shared/model.json +{"model": "a"} +EOF +cat <<'EOF' >shared/model.yml +model: b +EOF +cat <<'EOF' >mise.toml +[dotfiles] +"~/fmt.json/a" = { source = "shared/model.json", merge = true } +"~/fmt.yml/b" = { source = "shared/model.yml", merge = true } +EOF +assert_fail_contains "mise dotfiles apply --yes" "as different formats" +assert "cat ~/fmt.json" "{}" + +# the format conflict needs no rendering, so templates and dry runs see it too +cat <<'EOF' >shared/model.json.tera +{"model": "{{ 'a' }}"} +EOF +cat <<'EOF' >mise.toml +[dotfiles] +"~/fmt.json/a" = { source = "shared/model.json.tera", merge = true, template = "tera" } +"~/fmt.yml/b" = { source = "shared/model.yml", merge = true } +EOF +assert_fail_contains "mise dotfiles apply --dry-run" "as different formats" + +# a sibling whose target is a symlink cannot write anything, so it does not +# count as a format conflict for an entry applied through a filter +printf '{}\n' >~/sym.json +ln -sf ~/sym.json ~/sym-alias.yml +cat <<'EOF' >mise.toml +[dotfiles] +"~/sym.json/a" = { source = "shared/model.json", merge = true } +"~/sym-alias.yml/b" = { source = "shared/model.yml", merge = true } +EOF +assert_succeed "mise dotfiles apply ~/sym.json/a --yes" +assert_contains "cat ~/sym.json" '"model": "a"' + +# an unselected entry whose target is a named pipe is never opened by a filtered +# apply, so it cannot hang it +mkfifo ~/pipe.json +cat <<'EOF' >mise.toml +[dotfiles] +"~/ok.json/a" = { source = "shared/model.json", merge = true } +"~/pipe.json/b" = { source = "shared/model.json", merge = true } +EOF +printf '{}\n' >~/ok.json +assert_succeed "timeout 60 mise dotfiles apply ~/ok.json/a --yes" +assert_contains "cat ~/ok.json" '"model": "a"' + +# a filtered apply never renders an unselected template entry for the same +# file, so its exec() does not run +printf '{}\n' >~/shared-target.json +cat <<'EOF' >shared/exec.json.tera +{{ exec(command="touch $HOME/unselected-exec-ran") }}{"model": "x"} +EOF +cat <<'EOF' >mise.toml +[dotfiles] +"~/shared-target.json/a" = { source = "shared/model.json", merge = true } +"~/shared-target.json/b" = { source = "shared/exec.json.tera", merge = true, template = "tera" } +EOF +rm -f ~/unselected-exec-ran +assert_succeed "mise dotfiles apply ~/shared-target.json/a --yes" +assert_fail "test -e ~/unselected-exec-ran" diff --git a/schema/mise.json b/schema/mise.json index e62c136c85b..e3e79d9f344 100644 --- a/schema/mise.json +++ b/schema/mise.json @@ -6260,6 +6260,10 @@ "type": "string", "description": "comment prefix for edit marker lines; inferred from the file extension when omitted" }, + "merge": { + "type": "boolean", + "description": "edit entry: set the keys of source on a JSON, TOML, or YAML target and leave the target's other keys to the application that also writes it; the format comes from the target's extension" + }, "variants": { "type": "array", "description": "OS/profile variants: history streams for track mode, or optional destinations for deployment modes", @@ -6296,6 +6300,40 @@ } }, "allOf": [ + { + "if": { + "required": ["merge"], + "properties": { + "merge": { + "const": true + } + } + }, + "then": { + "required": ["source"], + "properties": { + "mode": false, + "content": false, + "permissions": false, + "exclude": false, + "include": false, + "manifest": false, + "autosave": false, + "encrypt": false, + "allow_plaintext": false, + "variants": false, + "enabled": false, + "remove_empty": false, + "relative": false, + "dot_prefix": false, + "group": false, + "block": false, + "line": false, + "position": false, + "comment": false + } + } + }, { "anyOf": [ { diff --git a/src/cli/dotfiles/edit.rs b/src/cli/dotfiles/edit.rs index 1debfb20be6..688fb721e8a 100644 --- a/src/cli/dotfiles/edit.rs +++ b/src/cli/dotfiles/edit.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use eyre::{Result, bail}; @@ -7,7 +7,6 @@ use crate::config::Config; use crate::file; use crate::path::PathExt; use crate::system; -use crate::system::edits::{BlockSource, EditOp}; use crate::system::files::FileMode; use crate::system::history::OperationScope; use crate::system::history::tracked::{self, TrackedSet}; @@ -189,17 +188,11 @@ fn source_for_target( match matching_edits.as_slice() { [] => {} [req] => { - return Ok(Some(match &req.op { - EditOp::Block { - source: BlockSource::File(path), - .. - } => path.clone(), - EditOp::Block { - source: BlockSource::Inline(_), - .. - } - | EditOp::Line { .. } => req.config_path.clone(), - })); + return Ok(Some( + req.op + .source_file() + .map_or_else(|| req.config_path.clone(), Path::to_path_buf), + )); } edits => { let keys = edits diff --git a/src/system/dotfile_groups.rs b/src/system/dotfile_groups.rs index 12634816a9b..b519c7d6631 100644 --- a/src/system/dotfile_groups.rs +++ b/src/system/dotfile_groups.rs @@ -364,12 +364,12 @@ fn group_entry(defaults: &EntryDefaults, key: &str, value: toml::Value) -> Resul toml::Value::Table(table) => table, _ => bail!("entries.{key:?}: expected a string or table"), }; - if ["block", "line", "template", "comment", "position"] + if ["block", "line", "template", "comment", "position", "merge"] .iter() .any(|k| table.contains_key(*k)) { bail!( - "entries.{key:?}: a group holds whole-file entries; declare block and line edits in [dotfiles]" + "entries.{key:?}: a group holds whole-file entries; declare block, line, and merge edits in [dotfiles]" ); } match table.get("group").map(|g| g.as_str()) { diff --git a/src/system/edits.rs b/src/system/edits.rs index 4dbe414d89c..38b92b4ef9e 100644 --- a/src/system/edits.rs +++ b/src/system/edits.rs @@ -29,6 +29,7 @@ use std::path::{Path, PathBuf}; use eyre::{Result, WrapErr, bail}; use indexmap::IndexMap; +use mise_util::structured_merge::{self, Format}; use serde::Deserialize; use crate::config::{Config, ConfigMap}; @@ -75,6 +76,11 @@ pub(crate) struct EditTomlTable { /// when omitted #[serde(default)] pub comment: Option, + /// `merge = true` sets the keys in `source` on a structured target file + /// (JSON, TOML, or YAML) and leaves every other key to the application + /// that also writes it + #[serde(default)] + pub merge: Option, } /// where a block's content comes from @@ -96,6 +102,38 @@ pub enum EditOp { line: String, position: LinePosition, }, + /// set the keys of a JSON/TOML/YAML source on the target and leave the + /// target's other keys alone + Merge { + source: BlockSource, + template: bool, + format: Format, + }, +} + +impl EditOp { + /// the source file this edit reads, if it reads one + pub fn source_file(&self) -> Option<&Path> { + match self { + Self::Block { + source: BlockSource::File(path), + .. + } + | Self::Merge { + source: BlockSource::File(path), + .. + } => Some(path), + _ => None, + } + } + + /// whether rendering the edit's content runs the template engine + fn is_template(&self) -> bool { + matches!( + self, + Self::Block { template: true, .. } | Self::Merge { template: true, .. } + ) + } } #[derive(Debug, Clone, Copy, Default, Eq, PartialEq)] @@ -130,6 +168,7 @@ impl EditRequest { match &self.op { EditOp::Block { .. } => format!("block:{}", self.id), EditOp::Line { .. } => format!("line:{}", self.id), + EditOp::Merge { .. } => format!("merge:{}", self.id), } } @@ -197,7 +236,7 @@ fn edit_requests_match(config: &Config, first: &EditRequest, second: &EditReques first.path == second.path && first.id == second.id && first.op == second.op - && (!matches!(first.op, EditOp::Block { template: true, .. }) + && (!first.op.is_template() || first.base == second.base && config.bootstrap_tera_ctx(&first.origin.config) == config.bootstrap_tera_ctx(&second.origin.config)) @@ -231,8 +270,69 @@ pub(crate) fn edits_from_config_files(config_files: &ConfigMap) -> Vec Result<()> { + if let Some(table) = value.as_table() { + for key in WHOLE_FILE_KEYS { + if table.contains_key(key) { + bail!( + "dotfile {path_and_id}: {key} applies to whole-file entries, not merge edits" + ); + } + } + } + let entry: EditTomlEntry = value + .clone() + .try_into() + .map_err(|err| eyre::eyre!("dotfile {path_and_id}: invalid merge entry: {err}"))?; + let Some((path_raw, id)) = split_edit_key(path_and_id) else { + bail!("dotfile {path_and_id}: edit entries must end with an id path segment"); + }; + let base = config_path.parent().unwrap_or(Path::new(".")); + resolve_entry(&path_raw, id, entry, base, config_path) + .map(|_| ()) + .map_err(|err| eyre::eyre!("dotfile {path_and_id}: {err}")) +} + fn edit_entry_from_toml(path_and_id: &str, value: toml::Value) -> Option { match &value { + toml::Value::Table(table) if table.contains_key("merge") => { + // a merge edits one structured file, so every key that shapes a + // whole-file entry is a mistake worth naming, not silently dropped + for key in WHOLE_FILE_KEYS { + if table.contains_key(key) { + warn!( + "[dotfiles].\"{path_and_id}\": {key} applies to whole-file entries, not merge edits, ignoring entry" + ); + return None; + } + } + } toml::Value::Table(table) => { let is_whole_file_table = table.is_empty() || table.contains_key("mode") @@ -311,9 +411,22 @@ fn resolve_entry( line: None, position: None, comment: None, + merge: None, }, EditTomlEntry::Table(table) => table, }; + if let Some(merge) = entry.merge { + let op = merge_op(path_raw, &id, &path, &entry, merge, base, &mut origin)?; + return Ok(EditRequest { + path_raw: path_raw.to_string(), + path, + id, + op, + base: base.to_path_buf(), + config_path: config_path.to_path_buf(), + origin, + }); + } let is_block = entry.block.is_some() || entry.source.is_some(); let op = match (&is_block, &entry.line) { (true, Some(_)) => { @@ -402,6 +515,56 @@ fn resolve_entry( }) } +/// the operation for `{ source = "...", merge = true }` +fn merge_op( + path_raw: &str, + id: &str, + path: &Path, + entry: &EditTomlTable, + merge: bool, + base: &Path, + origin: &mut ResourceOrigin, +) -> Result { + if !merge { + bail!("\"{path_raw}\".{id}: merge must be true when present, ignoring entry"); + } + if entry.block.is_some() || entry.line.is_some() { + bail!("\"{path_raw}\".{id}: merge cannot be combined with block or line, ignoring entry"); + } + if entry.position.is_some() || entry.comment.is_some() { + bail!("\"{path_raw}\".{id}: position and comment do not apply to merge, ignoring entry"); + } + let Some(source) = &entry.source else { + bail!("\"{path_raw}\".{id}: merge needs a source file, ignoring entry"); + }; + let Some(format) = Format::from_path(path) else { + bail!( + "\"{path_raw}\".{id}: merge needs a .json, .toml, .yaml, or .yml target, ignoring entry" + ); + }; + let source = file::replace_path(source); + let source = if source.is_relative() { + base.join(source) + } else { + source + }; + origin.source = Some(source.clone()); + let template = match entry.template.as_deref() { + None => false, + Some("tera") => true, + Some(other) => { + bail!( + "\"{path_raw}\".{id}: unknown template engine '{other}' (expected \"tera\"), ignoring entry" + ) + } + }; + Ok(EditOp::Merge { + source: BlockSource::File(source), + template, + format, + }) +} + /// comment prefix for marker lines, by file extension; `#` covers most /// config and shell files (and extensionless files like `.zshrc`, `hosts`) fn infer_comment(path: &Path) -> &'static str { @@ -475,13 +638,16 @@ fn find_block( /// the content a block should contain, resolved and rendered at most once /// per check/apply cycle (templates may use exec()) fn desired_content(config: &Config, req: &EditRequest) -> Result> { - let EditOp::Block { - source, - template, - comment, - } = &req.op - else { - return Ok(None); + let (source, template, comment) = match &req.op { + EditOp::Block { + source, + template, + comment, + } => (source, template, Some(comment)), + EditOp::Merge { + source, template, .. + } => (source, template, None), + EditOp::Line { .. } => return Ok(None), }; let id = &req.id; let raw = match source { @@ -505,6 +671,21 @@ fn desired_content(config: &Config, req: &EditRequest) -> Result> } else { raw }; + let Some(comment) = comment else { + // the source is data, not a block: a trailing newline can be part of + // a YAML block scalar, so keep it. Parse it now so a bad source is + // reported before any entry is written, even when the target is + // missing + if let EditOp::Merge { format, .. } = &req.op { + structured_merge::contains(*format, "", &content).wrap_err_with(|| { + format!( + "[dotfiles].\"{}/{}\": invalid merge source", + req.path_raw, req.id + ) + })?; + } + return Ok(Some(content)); + }; let content = content.trim_end_matches('\n').to_string(); // a block containing its own marker lines would write a file that can't // be parsed back — refuse up front instead of corrupting on reapply @@ -529,10 +710,7 @@ fn desired_content(config: &Config, req: &EditRequest) -> Result> /// target, missing file, absent or corrupted markers) has been ruled out, /// and `--dry-run` skips template rendering entirely (see [`apply`]). pub fn check(config: &Config, req: &EditRequest) -> Result { - if let EditOp::Block { - source: BlockSource::File(p), - .. - } = &req.op + if let Some(p) = req.op.source_file() && !p.exists() { return Ok(FileState::SourceMissing); @@ -579,6 +757,8 @@ fn precheck(req: &EditRequest) -> Result> { Ok(Some(_)) => Ok(None), } } + // whether the owned keys are in place takes a parse of both sides + EditOp::Merge { .. } => Ok(None), EditOp::Line { line, .. } => Ok(Some(EditCheck::State( if text .strip_prefix('\u{feff}') @@ -597,11 +777,20 @@ fn precheck(req: &EditRequest) -> Result> { /// content comparison for a block whose markers exist ([`precheck`] /// returned None) fn block_state(req: &EditRequest, desired: Option<&str>) -> Result { - let EditOp::Block { comment, .. } = &req.op else { - unreachable!("only blocks reach a content comparison"); + let text = file::read_to_string(&req.path)?; + let comment = match &req.op { + EditOp::Block { comment, .. } => comment, + EditOp::Merge { format, .. } => { + let desired = desired.expect("resolved merge content"); + return Ok(if structured_merge::contains(*format, &text, desired)? { + FileState::Applied + } else { + FileState::Differs("merged keys differ".into()) + }); + } + EditOp::Line { .. } => unreachable!("only blocks and merges reach a content comparison"), }; let id = &req.id; - let text = file::read_to_string(&req.path)?; let lines: Vec<&str> = text.lines().collect(); match find_block(&lines, id, comment) { Ok(Some((begin, end))) => { @@ -617,6 +806,185 @@ fn block_state(req: &EditRequest, desired: Option<&str>) -> Result { } } +/// Whether two edit targets are the same file under different names: equal +/// paths, existing files with the same identity (a hard link, or a case +/// variant on a case-insensitive volume), or missing files with the same name +/// in one directory reached by two spellings (a symlinked parent, say). +/// +/// A missing file has no identity, so a case-only difference in its name is +/// not guessed at: whether the directory ignores case can't be learned without +/// writing to it. Once the first of two such entries has created the file, the +/// pair is compared by identity and a conflict between them is refused. +fn same_target(a: &Path, b: &Path) -> bool { + if a == b { + return true; + } + if let Some(same) = same_identity(a, b) { + return same; + } + let (ancestor_a, tail_a) = split_existing(a); + let (ancestor_b, tail_b) = split_existing(b); + tail_a == tail_b + && (ancestor_a == ancestor_b || same_identity(&ancestor_a, &ancestor_b).unwrap_or(false)) +} + +/// Whether two existing paths are one file, or `None` when either is missing. +/// On Unix this compares device and inode from `stat`, which opens nothing, so a +/// named pipe or another path that blocks on open cannot hang the check. +fn same_identity(a: &Path, b: &Path) -> Option { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let (a, b) = (std::fs::metadata(a).ok()?, std::fs::metadata(b).ok()?); + Some(a.dev() == b.dev() && a.ino() == b.ino()) + } + #[cfg(not(unix))] + { + same_file::is_same_file(a, b).ok() + } +} + +/// The nearest existing ancestor of `path` (or `path` itself) and the +/// components below it. +fn split_existing(path: &Path) -> (PathBuf, Vec) { + let mut existing = path; + let mut tail = vec![]; + while !existing.exists() { + let (Some(parent), Some(name)) = (existing.parent(), existing.file_name()) else { + break; + }; + tail.push(name.to_os_string()); + existing = parent; + } + tail.reverse(); + (existing.to_path_buf(), tail) +} + +/// Two merge entries for one file that set the same key to different values +/// would each look unapplied after the other ran, so every apply would flip the +/// value back and forth. Refuse that instead of picking a winner. Every entry +/// this run applies is compared with the others it applies and with the +/// config's other entries for the file; entries this run leaves alone are not +/// compared with each other, since nothing here would write them. +fn merge_conflicts( + applied: &[(&EditRequest, Format, String)], + unapplied: &[(&EditRequest, Format, String)], +) -> Vec { + let mut problems = vec![]; + for (i, first) in applied.iter().enumerate() { + for second in applied[i + 1..].iter().chain(unapplied) { + problems.extend(merge_conflict(first, second)); + } + } + problems +} + +fn merge_conflict( + (first, format, first_content): &(&EditRequest, Format, String), + (second, second_format, second_content): &(&EditRequest, Format, String), +) -> Option { + if !same_target(&first.path, &second.path) || format != second_format { + return None; + } + // unparseable sources were already reported by desired_content + let keys = structured_merge::conflicts(*format, first_content, second_content).ok()?; + (!keys.is_empty()).then(|| { + format!( + " \"{}\": {} and {} set different values for {}", + first.path_raw, + first.describe_op(), + second.describe_op(), + keys.join(", ") + ) + }) +} + +/// Merge entries that reach one file through different extensions (hard links +/// under a .json and a .yml name) cannot be merged consistently as both +/// formats, whatever their sources hold. The format comes from the extension, +/// so this needs no rendering and holds on dry runs and for templates too. +fn format_conflicts(requests: &[EditRequest], siblings: &[EditRequest]) -> Vec { + fn merges(reqs: &[EditRequest]) -> Vec<(&EditRequest, Format)> { + reqs.iter() + .filter_map(|req| match &req.op { + EditOp::Merge { format, .. } => Some((req, *format)), + _ => None, + }) + .collect() + } + let applied = merges(requests); + let others: Vec<_> = merges(siblings) + .into_iter() + .filter(|(sibling, _)| { + !requests + .iter() + .any(|req| req.path == sibling.path && req.id == sibling.id) + }) + .collect(); + let mut problems = vec![]; + for (i, (first, first_format)) in applied.iter().enumerate() { + let candidates = applied[i + 1..] + .iter() + .map(|entry| (entry, false)) + .chain(others.iter().map(|entry| (entry, true))); + for ((second, second_format), is_sibling) in candidates { + if first_format == second_format || !same_target(&first.path, &second.path) { + continue; + } + // only now, for a sibling that really shares the file, look at its + // target: a blocked one (a symlink) cannot be written by that entry, + // and a target this run never touches is never opened + if is_sibling && !matches!(precheck(second), Ok(None | Some(EditCheck::State(_)))) { + continue; + } + problems.push(format!( + " \"{}\": {} and {} merge into one file as different formats", + first.path_raw, + first.describe_op(), + second.describe_op(), + )); + } + } + problems +} + +/// The merge entries of `siblings` that this run is not applying but that +/// target a file this run merges into, so a conflict with them is caught too. +/// A template sibling is never rendered, since that could run `exec()` for an +/// entry nobody asked to apply. A conflict involving a template is therefore +/// found only when every entry in it is applied in the same run, not when one +/// of them is applied alone through a target filter. Entries whose target is +/// blocked are skipped as well. +fn unapplied_siblings<'a>( + config: &Config, + requests: &[EditRequest], + siblings: &'a [EditRequest], + merged: &[(&EditRequest, Format, String)], +) -> Vec<(&'a EditRequest, Format, String)> { + let mut found = vec![]; + for sibling in siblings { + let EditOp::Merge { format, .. } = &sibling.op else { + continue; + }; + if requests + .iter() + .any(|req| req.path == sibling.path && req.id == sibling.id) + || !merged + .iter() + .any(|(req, ..)| same_target(&req.path, &sibling.path)) + || sibling.op.is_template() + || !matches!(precheck(sibling), Ok(None | Some(EditCheck::State(_)))) + { + continue; + } + // a sibling that cannot be rendered is reported when it is applied + if let Ok(Some(content)) = desired_content(config, sibling) { + found.push((sibling, *format, content)); + } + } + found +} + pub struct ApplyOpts { pub dry_run: bool, pub verbose: bool, @@ -640,11 +1008,20 @@ pub fn apply( ) -> Result { let mut todo: Vec<(&EditRequest, Option)> = vec![]; let mut problems = vec![]; + // other merge entries in the config, so applying one entry through a target + // filter still sees a sibling that sets the same key differently + let siblings = if requests + .iter() + .any(|req| matches!(req.op, EditOp::Merge { .. })) + { + edits_from_config(config).unwrap_or_default() + } else { + vec![] + }; + // every merge source rendered this run, for the cross-entry conflict check + let mut merged: Vec<(&EditRequest, Format, String)> = vec![]; for req in requests { - if let EditOp::Block { - source: BlockSource::File(p), - .. - } = &req.op + if let Some(p) = req.op.source_file() && !p.exists() { problems.push(format!( @@ -686,7 +1063,7 @@ pub fn apply( // rendering can run exec() — a dry run must not execute anything, // so template blocks are listed without computing their content // (same policy as template file entries) - if opts.dry_run && matches!(&req.op, EditOp::Block { template: true, .. }) { + if opts.dry_run && req.op.is_template() { todo.push((req, None)); continue; } @@ -698,6 +1075,9 @@ pub fn apply( continue; } }; + if let (EditOp::Merge { format, .. }, Some(content)) = (&req.op, &desired) { + merged.push((req, *format, content.clone())); + } match pre { // markers exist: compare content to see if anything would change None => match block_state(req, desired.as_deref()) { @@ -716,6 +1096,9 @@ pub fn apply( Some(_) => todo.push((req, desired)), } } + let unapplied = unapplied_siblings(config, requests, &siblings, &merged); + problems.extend(merge_conflicts(&merged, &unapplied)); + problems.extend(format_conflicts(requests, &siblings)); if !problems.is_empty() { bail!( "edits: cannot apply these entries, fix them manually:\n{}", @@ -730,8 +1113,7 @@ pub fn apply( for (req, desired) in &todo { // template state wasn't computed (no rendering on dry runs), so // the entry may already be converged - let conditional = - desired.is_none() && matches!(&req.op, EditOp::Block { template: true, .. }); + let conditional = desired.is_none() && req.op.is_template(); let suffix = if conditional { " (if changed)" } else { "" }; miseprintln!( "edit {} ({}){suffix}", @@ -776,10 +1158,7 @@ pub fn print_diffs(config: &Config, requests: &[EditRequest]) -> Result<()> { let mut changed = false; let mut problems = vec![]; for req in requests { - if let EditOp::Block { - source: BlockSource::File(path), - .. - } = &req.op + if let Some(path) = req.op.source_file() && !path.exists() { miseprintln!( @@ -918,6 +1297,11 @@ pub fn plan_unapply<'a>( let mut problems = vec![]; let mut seen_lines = indexmap::IndexSet::new(); for req in requests { + // merged keys carry no ownership record and may have been changed by + // the application since, so they stay put; the target is never read + if matches!(req.op, EditOp::Merge { .. }) { + continue; + } if req.path.is_symlink() { problems.push(format!( " \"{}\" ({}): {SYMLINK_REASON}", @@ -968,7 +1352,7 @@ pub fn plan_unapply<'a>( todo.push(UnapplyPlan { req, text }); } } - EditOp::Line { .. } => {} + EditOp::Line { .. } | EditOp::Merge { .. } => {} } } if !problems.is_empty() { @@ -1084,6 +1468,7 @@ fn unapply_one(req: &EditRequest) -> Result<()> { ), } } + EditOp::Merge { .. } => return Ok(()), EditOp::Line { line, position } => { // Use the occurrence nearest the configured insertion edge as the // best stateless approximation of the line mise added. @@ -1143,7 +1528,7 @@ pub fn apply_dry_run_to_string( req: &EditRequest, text: &str, ) -> Result> { - if matches!(&req.op, EditOp::Block { template: true, .. }) { + if req.op.is_template() { return Ok(None); } let desired = desired_content(config, req)?; @@ -1199,6 +1584,11 @@ fn apply_one(req: &EditRequest, desired: Option<&str>, written: &mut Vec, text: &str) -> Result { match &req.op { + EditOp::Merge { format, .. } => { + let desired = desired.expect("resolved merge content"); + structured_merge::merge(*format, text, desired) + .wrap_err_with(|| format!("merge into \"{}\" failed", req.path.display_user())) + } EditOp::Block { comment, .. } => { let mut lines: Vec = text.lines().map(|l| l.to_string()).collect(); let id = &req.id; @@ -1305,6 +1695,162 @@ mod tests { Ok(()) } + fn resolve(path: &str, entry: &str) -> Result { + let entry: EditTomlEntry = toml::from_str(entry).map_err(|e| eyre::eyre!("{e}"))?; + resolve_entry( + path, + "shared".into(), + entry, + Path::new("/cfg"), + Path::new("/cfg/mise.toml"), + ) + } + + #[test] + fn merge_entries_infer_the_format_from_the_target() { + for (path, format) in [ + ("~/a/config.toml", Format::Toml), + ("~/a/settings.json", Format::Json), + ("~/a/config.yml", Format::Yaml), + ("~/a/config.yaml", Format::Yaml), + ] { + let req = resolve(path, "source = \"shared.txt\"\nmerge = true").unwrap(); + assert_eq!( + req.op, + EditOp::Merge { + source: BlockSource::File(PathBuf::from("/cfg/shared.txt")), + template: false, + format, + } + ); + assert_eq!(req.describe_op(), "merge:shared"); + assert_eq!(req.origin.source, Some(PathBuf::from("/cfg/shared.txt"))); + } + } + + #[test] + fn merge_entries_can_be_templates() { + let req = resolve( + "~/a/config.toml", + "source = \"s.toml.tera\"\nmerge = true\ntemplate = \"tera\"", + ) + .unwrap(); + assert!(req.op.is_template()); + } + + #[test] + fn invalid_merge_entries_are_refused() { + for (path, entry, reason) in [ + ( + "~/a/notes.txt", + "source = \"s\"\nmerge = true", + ".json, .toml", + ), + ( + "~/a/config.toml", + "source = \"s\"\nmerge = false", + "must be true", + ), + ("~/a/config.toml", "merge = true", "needs a source"), + ( + "~/a/config.toml", + "block = \"x\"\nmerge = true", + "block or line", + ), + ( + "~/a/config.toml", + "line = \"x\"\nmerge = true", + "block or line", + ), + ( + "~/a/config.toml", + "source = \"s\"\nmerge = true\ncomment = \"#\"", + "do not apply", + ), + ( + "~/a/config.toml", + "source = \"s\"\nmerge = true\ntemplate = \"jinja\"", + "unknown template engine", + ), + ] { + let err = resolve(path, entry).unwrap_err().to_string(); + assert!(err.contains(reason), "{entry}: {err}"); + } + } + + #[test] + fn incoming_merge_entries_are_checked_like_the_edit_parser_reads_them() { + let check = |key: &str, entry: &str| { + let value: toml::Value = toml::from_str(entry).unwrap(); + validate_incoming_merge(key, &value, Path::new("/cfg/mise.toml")) + }; + assert!( + check( + "~/a/settings.json/shared", + "source = \"s.json\"\nmerge = true" + ) + .is_ok() + ); + for (key, entry, reason) in [ + ( + "~/a/settings.json/shared", + "source = \"s\"\nmerge = true\nexclude = []", + "exclude applies to whole-file", + ), + ( + "~/a/notes.txt/shared", + "source = \"s\"\nmerge = true", + ".json, .toml", + ), + ("~/a/settings.json/shared", "merge = true", "needs a source"), + ( + "~/a/settings.json/shared", + "source = \"s\"\nmerge = false", + "must be true", + ), + ] { + let err = check(key, entry).unwrap_err().to_string(); + assert!(err.contains(reason), "{entry}: {err}"); + } + } + + #[test] + fn a_merge_table_is_an_edit_not_a_whole_file_entry() { + let value: toml::Value = toml::from_str("source = \"s.toml\"\nmerge = true").unwrap(); + assert!(edit_entry_from_toml("~/a/config.toml/shared", value).is_some()); + let value: toml::Value = toml::from_str("source = \"s.toml\"").unwrap(); + assert!(edit_entry_from_toml("~/a/config.toml", value).is_none()); + } + + #[cfg(unix)] + #[test] + fn merge_targets_are_the_same_only_when_provably_so() -> Result<()> { + let dir = tempfile::tempdir()?; + let (a, b) = (dir.path().join("a.toml"), dir.path().join("b.toml")); + file::write(&a, "x = 1\n")?; + file::write(&b, "x = 1\n")?; + assert!(same_target(&a, &a)); + assert!(!same_target(&a, &b)); + // a hard link is the same file + let link = dir.path().join("link.toml"); + std::fs::hard_link(&a, &link)?; + assert!(same_target(&a, &link)); + // missing targets: equal paths are the same + let missing = dir.path().join("m.toml"); + assert!(same_target(&missing, &missing)); + // two spellings of one existing directory, same missing file name + let real = dir.path().join("real"); + file::create_dir_all(&real)?; + let alias = dir.path().join("alias"); + std::os::unix::fs::symlink(&real, &alias)?; + assert!(same_target(&real.join("new.toml"), &alias.join("new.toml"))); + assert!(!same_target( + &real.join("new.toml"), + &alias.join("other.toml") + )); + Ok(()) + } + #[test] fn test_infer_comment() { assert_eq!(infer_comment(Path::new("/a/.zshrc")), "#"); diff --git a/src/system/files.rs b/src/system/files.rs index 432a2ecf298..1e5ee2beb49 100644 --- a/src/system/files.rs +++ b/src/system/files.rs @@ -945,7 +945,7 @@ pub(crate) fn validate_incoming_files(config_files: &ConfigMap) -> Result<()> { } if value.as_table().is_some_and(|t| { t.get("encrypt").and_then(toml::Value::as_bool) == Some(true) - && ["content", "block", "line", "template"] + && ["content", "block", "line", "template", "merge"] .iter() .any(|key| t.contains_key(*key)) }) { @@ -957,7 +957,7 @@ pub(crate) fn validate_incoming_files(config_files: &ConfigMap) -> Result<()> { // Managed line/block edits are handled by the edit engine, // not by this whole-file declaration parser. if let Some(table) = value.as_table().filter(|table| { - ["block", "line", "template", "comment", "position"] + ["block", "line", "template", "comment", "position", "merge"] .iter() .any(|key| table.contains_key(*key)) }) { @@ -968,6 +968,9 @@ pub(crate) fn validate_incoming_files(config_files: &ConfigMap) -> Result<()> { ); } } + if table.contains_key("merge") { + crate::system::edits::validate_incoming_merge(&target, &value, path)?; + } continue; } bail!("invalid dotfile declaration {target} in {}", path.display()); @@ -1312,7 +1315,7 @@ const GROUP_KEY_IN_DOTFILES: &str = fn parse_file_entry(target: &str, value: toml::Value, config: &Path) -> Option { if value.as_table().is_some_and(|t| { t.get("encrypt").and_then(toml::Value::as_bool) == Some(true) - && ["content", "block", "line", "template"] + && ["content", "block", "line", "template", "merge"] .iter() .any(|key| t.contains_key(*key)) }) { @@ -1328,7 +1331,7 @@ fn parse_file_entry(target: &str, value: toml::Value, config: &Path) -> Option Option Option { match &value { toml::Value::String(_) => {} + // a merge is an edit of a structured file, whatever else the table says; + // the edit parser accepts it or says why not + toml::Value::Table(table) if table.contains_key("merge") => return None, toml::Value::Table(table) if table.is_empty() || table.contains_key("mode") @@ -7813,6 +7819,26 @@ source = "oldrc""#, validate_incoming_files(&configs) } + #[test] + fn incoming_merge_entries_are_validated_as_edits() -> Result<()> { + incoming( + "[dotfiles]\n\"~/a/settings.json/shared\" = { source = \"s.json\", merge = true }\n", + )?; + for entry in [ + r#"{ source = "s.json", merge = true, exclude = [] }"#, + r#"{ source = "s.json", merge = true, mode = "copy" }"#, + r#"{ merge = true }"#, + ] { + let body = format!("[dotfiles]\n\"~/a/settings.json/shared\" = {entry}\n"); + assert!(incoming(&body).is_err(), "{entry}"); + } + assert!( + incoming("[dotfiles]\n\"~/a/notes.txt/shared\" = { source = \"s\", merge = true }\n") + .is_err() + ); + Ok(()) + } + #[test] fn permissions_parse_as_octal_strings() { assert_eq!(parse_permissions("0600").unwrap(), 0o600);