defaults_rs/preferences/
mod.rs

1// SPDX-License-Identifier: MIT
2
3//! Preferences API for defaults-rs.
4//!
5//! This module implements all business logic for reading, writing, deleting, importing/exporting,
6//! batch operations, and pretty-printing macOS preferences (plist files).
7//!
8//! It acts as the main interface between the CLI/library and the backend (CoreFoundation or file-based).
9
10mod convert;
11pub mod types;
12
13use anyhow::{Context, Result, bail};
14use std::{
15    collections::{HashMap, HashSet},
16    fs::{self, File},
17    io::Cursor,
18    path::PathBuf,
19};
20
21use crate::{
22    Domain, FindMatch, PrefValue,
23    preferences::convert::{plist_to_prefvalue, prefvalue_to_plist},
24};
25use plist::Value;
26
27/// Backend selection for preferences (CoreFoundation vs File)
28use crate::core::foundation;
29
30/// Provides operations for reading, writing, deleting, and managing
31/// macOS plist preference files in user or global domains.
32pub struct Preferences;
33
34impl Preferences {
35    /// List all available domains.
36    pub fn list_domains() -> Result<HashSet<Domain>> {
37        let list = foundation::list_domains()?;
38
39        let domains: HashSet<Domain> = list.iter().map(|f| Domain::User(f.to_string())).collect();
40        Ok(domains)
41    }
42
43    /// Search all domains for keys or values containing the given word (case-insensitive).
44    pub fn find(word: &str) -> Result<HashMap<Domain, Vec<FindMatch>>> {
45        let word_lower = word.to_lowercase();
46        let mut results: std::collections::HashMap<Domain, Vec<FindMatch>> =
47            std::collections::HashMap::new();
48
49        let domains: Vec<Domain> = Self::list_domains()?
50            .into_iter()
51            .chain([Domain::Global])
52            .collect();
53
54        for domain in domains {
55            let loaded = foundation::read_pref_domain(&domain.to_string())?;
56            let mut matches = Vec::new();
57
58            Self::find_in_value(&loaded, &word_lower, String::new(), &mut matches);
59            if !matches.is_empty() {
60                results.insert(domain, matches);
61            }
62        }
63        Ok(results)
64    }
65
66    /// Recursively searches a plist Value.
67    fn find_in_value(
68        val: &PrefValue,
69        word_lower: &str,
70        key_path: String,
71        matches: &mut Vec<FindMatch>,
72    ) {
73        fn contains_word(haystack: &str, needle: &str) -> bool {
74            haystack.to_lowercase().contains(needle)
75        }
76        match val {
77            PrefValue::Dictionary(dict) => {
78                for (k, v) in dict {
79                    let new_key_path = if key_path.is_empty() {
80                        k.clone()
81                    } else {
82                        format!("{key_path}.{k}")
83                    };
84                    if contains_word(k, word_lower) {
85                        matches.push(FindMatch {
86                            key: new_key_path.clone(),
87                            value: v.clone(),
88                        });
89                    }
90                    Self::find_in_value(v, word_lower, new_key_path, matches);
91                }
92            }
93            PrefValue::Array(arr) => {
94                for (i, v) in arr.iter().enumerate() {
95                    let new_key_path = format!("{key_path}[{i}]");
96                    Self::find_in_value(v, word_lower, new_key_path, matches);
97                }
98            }
99            _ => {
100                if contains_word(&val.to_string(), word_lower) {
101                    matches.push(FindMatch {
102                        key: key_path.clone(),
103                        value: val.clone(),
104                    });
105                }
106            }
107        }
108    }
109
110    /// Read a value from the given domain and key.
111    pub fn read(domain: Domain, key: &str) -> Result<PrefValue> {
112        let cf_name = &domain.get_cf_name();
113        foundation::read_pref(cf_name, key)
114    }
115
116    /// Read an entire domain.
117    pub fn read_domain(domain: Domain) -> Result<PrefValue> {
118        let cf_name = &domain.get_cf_name();
119        foundation::read_pref_domain(cf_name)
120    }
121
122    /// Write a value to the given domain and key.
123    ///
124    /// If the domain file does not exist, it will be created.
125    /// If the key already exists, its value will be overwritten.
126    pub fn write(domain: Domain, key: &str, value: PrefValue) -> Result<()> {
127        let cf_name = &domain.get_cf_name();
128        foundation::write_pref(cf_name, key, &value)?;
129
130        Ok(())
131    }
132
133    /// Delete a key from the given domain.
134    pub fn delete(domain: Domain, key: &str) -> Result<()> {
135        let cf_name = &domain.get_cf_name();
136        foundation::delete_key(cf_name, key)
137    }
138
139    /// Delete a whole domain.
140    pub fn delete_domain(domain: Domain) -> Result<()> {
141        let cf_name = &domain.get_cf_name();
142        foundation::delete_domain(cf_name)
143    }
144
145    /// Read the type of a value at the given key in the specified domain.
146    ///
147    /// Returns a string describing the type.
148    pub fn read_type(domain: Domain, key: &str) -> Result<String> {
149        let cf_name = domain.get_cf_name();
150        let loaded = foundation::read_pref(&cf_name, key)?;
151
152        Ok(loaded.get_type().to_string())
153    }
154
155    /// Rename a key in the given domain.
156    ///
157    /// Moves the value from `old_key` to `new_key` within the domain plist.
158    pub fn rename(domain: Domain, old_key: &str, new_key: &str) -> Result<()> {
159        let cf_name = &domain.get_cf_name();
160
161        // Read old value
162        let val = foundation::read_pref(cf_name, old_key)?;
163
164        foundation::write_pref(cf_name, new_key, &val)?;
165        foundation::delete_key(cf_name, old_key)?;
166
167        Ok(())
168    }
169
170    /// Import a plist file into the specified domain.
171    ///
172    /// Replaces any existing file for the domain.
173    pub fn import(domain: Domain, import_path: &str) -> Result<()> {
174        let data = fs::read(import_path)?;
175
176        let plist_val = Value::from_reader(Cursor::new(&data))?;
177
178        let dict = match plist_val {
179            Value::Dictionary(d) => d,
180            _ => {
181                bail!("Import must be a dictionary at root.")
182            }
183        };
184
185        let cf_name = &domain.get_cf_name();
186        for (k, v) in dict {
187            let pv = plist_to_prefvalue(&v)?;
188            foundation::write_pref(cf_name, &k, &pv)?;
189        }
190        Ok(())
191    }
192
193    /// Export a domain's plist file to the specified path.
194    pub fn export(domain: Domain, export_path: &str) -> Result<()> {
195        let cf_name = &domain.get_cf_name();
196        let pref = foundation::read_pref_domain(cf_name)?;
197
198        if !matches!(pref, PrefValue::Dictionary(_)) {
199            bail!("CF export produced non-dictionary root")
200        }
201
202        let plist = prefvalue_to_plist(&pref);
203        let path = PathBuf::from(export_path);
204
205        let file = File::create(path)?;
206        plist
207            .to_writer_binary(file)
208            .context("failed to export CF domain to plist")?;
209
210        Ok(())
211    }
212}