Skip to content

Commit

Permalink
Merge dictionaries by default (with "!" syntax to overwrite).
Browse files Browse the repository at this point in the history
  • Loading branch information
mmorella-dev committed May 25, 2024
1 parent 79b19ef commit 2d84bd5
Show file tree
Hide file tree
Showing 2 changed files with 135 additions and 68 deletions.
29 changes: 17 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,31 +90,36 @@ data:
ABIncludePhotosInVCard: true
```
You may also use full paths to `.plist` files instead of domain names.
You may also use full paths to `.plist` files instead of domain names. This is the only way to set values in /Library/Preferences/.

This is the only way to set values in /Library/Preferences/
### Overwrite syntax

### Dictionary merge syntax
By default, the YAML will be merged against existing domains.

If a dictionary property contains the key `...`, it will be merged with the previous value of that dictionary. Keys before the `...` take precendece, then the old keys, then keys after it.

For example, the following config:
For example, the following config will leave any other keys on `DesktopViewSettings:IconViewSettings` untouched:
```yaml
data:
com.apple.finder:
DesktopViewSettings:
IconViewSettings:
labelOnBottom: false # item info on right
...: {}
iconSize: 80.0
```

This can be overridden by adding the key `"!"` to a dict, which will delete any keys which are not specified. For example, the following config will delete all properties on the com.apple.finder domain except for DesktopViewSettings, and likewise, all properties on `IconViewSettings` except those specified.

```yaml
data:
com.apple.finder:
"!": {} # overwrite!
DesktopViewSettings:
IconViewSettings:
"!": {} # overwrite!
labelOnBottom: false # item info on right
iconSize: 80.0
```
Will perform the following actions:
* Set `DesktopViewSettings:IconViewSettings:labelOnBottom` to true,
* Set `DesktopViewSettings:IconViewSettings:iconSize` to 80.0, but only if it doesn't already exist.
* Keep all other keys on `IconViewSettings` as-is.

If `...` isn't present, the dictionary will be fully overwritten, and keys not specified will be deleted.
This feature has the potential to erase important settings, so exercise caution. Running `macos-defaults apply` creates a backup of each modified plist at, for example, `~/Library/Preferences/com.apple.finder.plist.prev`.

### Array merge syntax

Expand Down
174 changes: 118 additions & 56 deletions src/defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ use camino::{Utf8Path, Utf8PathBuf};
use color_eyre::eyre::{eyre, Result};
use displaydoc::Display;
use duct::cmd;
use itertools::Itertools;
use log::{debug, info, trace, warn};
use plist::{Dictionary, Value};
use serde::{Deserialize, Serialize};
Expand All @@ -17,8 +16,10 @@ use std::mem;

use super::errors::DefaultsError as E;

/// A value or key-value pair that means "insert existing values here" for arrays and dictionaries.
/// A value in an array that means "insert existing values here"
const ELLIPSIS: &str = "...";
/// A value in a dictionary or domain that means "delete any keys not specified here".
const BANG: &str = "!";

pub const NS_GLOBAL_DOMAIN: &str = "NSGlobalDomain";

Expand Down Expand Up @@ -186,7 +187,7 @@ fn is_binary(file: &Utf8Path) -> Result<bool, E> {
}

/// Write a `HashMap` of key-value pairs to a plist file.
pub(super) fn write_defaults_values(domain: &str, prefs: HashMap<String, plist::Value>, current_host: bool) -> Result<bool> {
pub(super) fn write_defaults_values(domain: &str, mut prefs: HashMap<String, plist::Value>, current_host: bool) -> Result<bool> {
let plist_path = plist_path(domain, current_host)?;

debug!("Plist path: {plist_path}");
Expand All @@ -207,6 +208,12 @@ pub(super) fn write_defaults_values(domain: &str, prefs: HashMap<String, plist::
// Whether we changed anything.
let mut values_changed = false;

// If we have a key "!", wipe out the existing array.
if prefs.contains_key(BANG) {
plist_value = Value::from(Dictionary::new());
prefs.remove(BANG);
}

for (key, mut new_value) in prefs {
let old_value = plist_value
.as_dictionary()
Expand All @@ -222,8 +229,8 @@ pub(super) fn write_defaults_values(domain: &str, prefs: HashMap<String, plist::
{new_value:?}"
);

// Handle `...` values in arrays or dicts provided in input.
replace_ellipsis(&mut new_value, old_value);
// Performs merge operations
merge_value(&mut new_value, old_value);

if let Some(old_value) = old_value {
if old_value == &new_value {
Expand Down Expand Up @@ -335,21 +342,27 @@ fn write_plist(plist_path_exists: bool, plist_path: &Utf8Path, plist_value: &pli
Ok(())
}

/// Replace "..." as an array value or dictionary key
fn replace_ellipsis(new_value: &mut Value, old_value: Option<&Value>) {
match new_value {
Value::Array(arr) => replace_ellipsis_array(arr, old_value),
Value::Dictionary(dict) => replace_ellipsis_dict(dict, old_value),
_ => trace!("Value isn't an array or a dict, skipping ellipsis replacement..."),
}
/// Combines plist values using the following operations:
/// * Merges dictionaries so new keys apply and old keys are let untouched
/// * Replaces "..." in arrays with a copy of the old array (duplicates removed)
///
/// This operation is performed recursively on dictionaries.
fn merge_value(new_value: &mut Value, old_value: Option<&Value>) {
deep_merge_dictionaries(new_value, old_value);
replace_ellipsis_array(new_value, old_value);
}

/// Replace `...` values in an input array.
/// You end up with: [<new values before ...>, <old values>, <new values after ...>]
/// But any duplicates between old and new values are removed, with the first value taking
/// precedence.
fn replace_ellipsis_array(new_array: &mut Vec<Value>, old_value: Option<&Value>) {
let ellipsis = plist::Value::String("...".to_owned());
fn replace_ellipsis_array(new_value: &mut Value, old_value: Option<&Value>) {
let Value::Array(new_array) = new_value else {
trace!("Value isn't an array, skipping ellipsis replacement...");
return;
};

let ellipsis = plist::Value::from(ELLIPSIS);
let Some(position) = new_array.iter().position(|x| x == &ellipsis) else {
trace!("New value doesn't contain ellipsis, skipping ellipsis replacement...");
return;
Expand Down Expand Up @@ -378,36 +391,43 @@ fn replace_ellipsis_array(new_array: &mut Vec<Value>, old_value: Option<&Value>)
}
}

/// Replace `...` keys in an input dict.
/// You end up with: [<new contents before ...>, <old contents>, <new contents after ...>]
/// But any duplicates between old and new values are removed, with the first value taking
/// precedence.
///
/// If an entry of this element is a dictionary or an array, this applies recursively.
fn replace_ellipsis_dict(new_dict: &mut Dictionary, old_value: Option<&Value>) {
// recursively call replace elipses on entry values, with their corresponding value from the old dictionary
for (key, child_value) in &mut *new_dict {
let old_child_value = old_value.and_then(Value::as_dictionary).and_then(|old_dict| old_dict.get(key));
replace_ellipsis(child_value, old_child_value);
}

if !new_dict.contains_key(ELLIPSIS) {
trace!("New value doesn't contain ellipsis, skipping ellipsis replacement...");
/// Recursively merge dictionaries, unless the new value is empty `{}`.
/// If a dictionary
/// * is empty `{}`
/// * contains a key `{}`
/// Then the merge step will be skipped for it and its children.
fn deep_merge_dictionaries(new_value: &mut Value, old_value: Option<&Value>) {
let Value::Dictionary(new_dict) = new_value else {
trace!("New value is not a dictionary, Skipping merge...");
return;
};
if new_dict.is_empty() {
trace!("New value is an empty dictionary. Skipping merge...");
return;
}

let before = new_dict.keys().take_while(|x| x != &ELLIPSIS).cloned().collect_vec();
new_dict.remove(ELLIPSIS);

let Some(old_dict) = old_value.and_then(plist::Value::as_dictionary) else {
trace!("Old value wasn't a dict, skipping ellipsis replacement...");
trace!("Old value wasn't a dict. Skipping merge...");
return;
};

// for each value, recursively invoke this to merge any child dictionaries.
// also perform array ellipsis replacment.
// this occurs even if "!" is present.
for (key, new_child_value) in &mut *new_dict {
let old_child_value = old_dict.get(key);
merge_value(new_child_value, old_child_value);
}


trace!("Performing dict ellipsis replacement...");
for (key, value) in old_dict {
if !before.contains(key) {
new_dict.insert(key.clone(), value.clone());
if new_dict.contains_key(BANG) {
trace!("Dictionary contains key '!'. Skipping merge...");
new_dict.remove(BANG);
return;
}
trace!("Performing deep merge...");
for (key, old_value) in old_dict {
if !new_dict.contains_key(key) {
new_dict.insert(key.clone(), old_value.clone());
}
}
}
Expand Down Expand Up @@ -473,7 +493,9 @@ mod tests {
use log::info;
use testresult::TestResult;

use super::{replace_ellipsis, NS_GLOBAL_DOMAIN};
use crate::defaults::deep_merge_dictionaries;

use super::{replace_ellipsis_array, NS_GLOBAL_DOMAIN};
// use serial_test::serial;

#[test]
Expand Down Expand Up @@ -562,30 +584,29 @@ mod tests {
}

#[test]
fn test_replace_ellipsis_dict() -> TestResult {
fn test_deep_merge_dictionaries() -> TestResult {
use plist::{Dictionary, Value};

let old_value = Dictionary::from_iter([
("foo", Value::from(10)), // !!
("bar", 20.into()), // !! takes precedence
("foo", Value::from(10)), // !!! takes precedence
("fub", 11.into()), // !!!
("bar", 12.into()), // !
("baz", 13.into()), // !
])
.into();
let mut new_value = Dictionary::from_iter([
("foo", Value::from(30)), // !!! takes precedence
("fub", 40.into()), // !!!
("...", "".into()),
("bar", 60.into()), // !
("baz", 70.into()), // !
("bar", Value::from(22)), // !!
("baz", 23.into()), // !! takes precedence
])
.into();

replace_ellipsis(&mut new_value, Some(&old_value));
deep_merge_dictionaries(&mut new_value, Some(&old_value));

let expected = Dictionary::from_iter([
("foo", Value::from(30)), // from new
("fub", 40.into()),
("bar", 20.into()), // from old
("baz", 70.into()),
("foo", Value::from(10)), // from new
("fub", 11.into()),
("bar", 22.into()), // from old
("baz", 23.into()),
])
.into();

Expand Down Expand Up @@ -617,13 +638,12 @@ mod tests {
"level_2",
Dictionary::from_iter([
("baz", Value::from(90)), //
("...", Value::from("")),
]), //
]),
)]),
)])
.into();

replace_ellipsis(&mut new_value, Some(&old_value));
deep_merge_dictionaries(&mut new_value, Some(&old_value));

let expected = Dictionary::from_iter([(
"level_1",
Expand All @@ -643,6 +663,48 @@ mod tests {
Ok(())
}

#[test]
fn test_replace_ellipsis_dict_nested_bang() -> TestResult {
use plist::{Dictionary, Value};

let old_value = Dictionary::from_iter([(
"level_1",
Dictionary::from_iter([(
"level_2",
Dictionary::from_iter([
("foo", Value::from(10)), //
("bar", 20.into()),
("baz", 30.into()),
]),
)]),
)])
.into();

let mut new_value = Dictionary::from_iter([(
"level_1",
Dictionary::from_iter([(
"level_2",
Dictionary::from_iter([
("!", Value::from("")), //
("baz", 90.into()), //
]),
)]),
)])
.into();

deep_merge_dictionaries(&mut new_value, Some(&old_value));

let expected = Dictionary::from_iter([(
"level_1",
Dictionary::from_iter([("level_2", Dictionary::from_iter([("baz", Value::from(90))]))]),
)])
.into();

assert_eq!(new_value, expected);

Ok(())
}

#[test]
fn test_replace_ellipsis_array() -> TestResult {
let old_value = vec![
Expand All @@ -662,7 +724,7 @@ mod tests {
]
.into();

replace_ellipsis(&mut new_value, Some(&old_value));
replace_ellipsis_array(&mut new_value, Some(&old_value));

let expected = vec![
30.into(), // from new array before "..."
Expand Down

0 comments on commit 2d84bd5

Please sign in to comment.