use serde::Serialize;
use crate::commands::MessageResult;
use crate::packs::orchestration::ExecutionContext;
use crate::{DodotError, Result};
pub mod result {
pub use crate::commands::MessageResult;
}
pub fn config_block_text() -> String {
[
"[filter \"dodot-plist\"]",
" clean = dodot plist clean",
" smudge = dodot plist smudge",
" required = true",
]
.join("\n")
}
pub fn gitattributes_lines(extensions: &[String]) -> Vec<String> {
extensions
.iter()
.map(|ext| format!("*.{ext} filter=dodot-plist"))
.collect()
}
pub fn normalize_plist_extensions(raw: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::with_capacity(raw.len());
for entry in raw {
let trimmed = entry.trim();
let stripped = trimmed.strip_prefix('.').unwrap_or(trimmed);
if stripped.is_empty() {
continue;
}
if !stripped
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+'))
{
continue;
}
let lower = stripped.to_ascii_lowercase();
if !out.contains(&lower) {
out.push(lower);
}
}
out
}
pub(crate) fn root_plist_extensions(ctx: &ExecutionContext) -> Result<Vec<String>> {
Ok(normalize_plist_extensions(
&ctx.config_manager.root_config()?.symlink.plist_extensions,
))
}
pub fn install_filters(ctx: &ExecutionContext) -> Result<MessageResult> {
let root = ctx.paths.dotfiles_root().to_path_buf();
let runner = ctx.command_runner.as_ref();
if filter_is_installed(runner, &root)? {
let mut details = Vec::new();
append_gitattributes_hint(ctx, &mut details);
return Ok(MessageResult {
message: "Plist filters already installed in .git/config.".into(),
details,
});
}
git_config_set(
runner,
&root,
"filter.dodot-plist.clean",
"dodot plist clean",
)?;
git_config_set(
runner,
&root,
"filter.dodot-plist.smudge",
"dodot plist smudge",
)?;
git_config_set(runner, &root, "filter.dodot-plist.required", "true")?;
let mut details = vec![format!(
"Wrote [filter \"dodot-plist\"] to {}/.git/config",
root.display()
)];
append_gitattributes_hint(ctx, &mut details);
append_cfprefsd_hint(&mut details);
Ok(MessageResult {
message: "Installed plist clean/smudge filters.".into(),
details,
})
}
pub fn show_filters(ctx: &ExecutionContext) -> Result<ShowFiltersResult> {
let root = ctx.paths.dotfiles_root().to_path_buf();
let runner = ctx.command_runner.as_ref();
let installed = filter_is_installed(runner, &root)?;
let extensions = root_plist_extensions(ctx)?;
let expected_lines = gitattributes_lines(&extensions);
let attrs_content = ctx
.fs
.read_to_string(&root.join(".gitattributes"))
.unwrap_or_default();
let attributes_present = !expected_lines.is_empty()
&& expected_lines.iter().all(|expected| {
attrs_content
.lines()
.any(|existing| gitattributes_line_matches(existing, expected))
});
let block = config_block_text();
let block_lines = block.lines().map(str::to_string).collect();
Ok(ShowFiltersResult {
config_block: block,
config_block_lines: block_lines,
gitattributes_lines: expected_lines,
installed_in_git_config: installed,
bound_in_gitattributes: attributes_present,
repo_root: root.display().to_string(),
})
}
#[derive(Debug, Clone, Serialize)]
pub struct ShowFiltersResult {
pub config_block: String,
pub config_block_lines: Vec<String>,
pub gitattributes_lines: Vec<String>,
pub installed_in_git_config: bool,
pub bound_in_gitattributes: bool,
pub repo_root: String,
}
pub fn is_installed(ctx: &ExecutionContext) -> Result<bool> {
let runner = ctx.command_runner.as_ref();
let root = ctx.paths.dotfiles_root();
filter_is_installed(runner, root)
}
pub fn detect_plist_files(ctx: &ExecutionContext) -> Result<Vec<std::path::PathBuf>> {
let root = ctx.paths.dotfiles_root();
if !ctx.fs.is_dir(root) {
return Ok(Vec::new());
}
let root_config = ctx.config_manager.root_config()?;
let packs = crate::packs::discover_packs(ctx.fs.as_ref(), root, &root_config.pack.ignore)?;
detect_plist_files_in(ctx, &packs)
}
pub fn detect_plist_files_in(
ctx: &ExecutionContext,
packs: &[crate::packs::Pack],
) -> Result<Vec<std::path::PathBuf>> {
let mut found = Vec::new();
for pack in packs {
let pack_config = ctx.config_manager.config_for_pack(&pack.path)?;
let extensions = normalize_plist_extensions(&pack_config.symlink.plist_extensions);
scan_for_plists(ctx.fs.as_ref(), &pack.path, &extensions, &mut found)?;
}
Ok(found)
}
fn scan_for_plists(
fs: &dyn crate::fs::Fs,
dir: &std::path::Path,
extensions: &[String],
found: &mut Vec<std::path::PathBuf>,
) -> Result<()> {
let entries = match fs.read_dir(dir) {
Ok(e) => e,
Err(_) => return Ok(()), };
for entry in entries {
if entry.is_dir {
let name = entry
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if name.starts_with('.') {
continue;
}
scan_for_plists(fs, &entry.path, extensions, found)?;
} else if entry
.path
.extension()
.and_then(|e| e.to_str())
.map(|ext| {
extensions
.iter()
.any(|configured| configured.eq_ignore_ascii_case(ext))
})
.unwrap_or(false)
{
found.push(entry.path);
}
}
Ok(())
}
fn append_cfprefsd_hint(details: &mut Vec<String>) {
if !cfg!(target_os = "macos") {
return;
}
details.push(String::new());
details.push("Note: macOS caches plist values in `cfprefsd`. After pulling".into());
details.push("plist changes from another machine, run:".into());
details.push(" killall cfprefsd".into());
details.push("to make running apps re-read their preferences. (No data loss;".into());
details.push("cfprefsd respawns immediately.)".into());
}
fn append_gitattributes_hint(ctx: &ExecutionContext, details: &mut Vec<String>) {
let extensions = match root_plist_extensions(ctx) {
Ok(e) => e,
Err(_) => return, };
let lines = gitattributes_lines(&extensions);
let attrs_content = ctx
.fs
.read_to_string(&ctx.paths.dotfiles_root().join(".gitattributes"))
.unwrap_or_default();
let missing: Vec<&str> = lines
.iter()
.filter(|line| {
!attrs_content
.lines()
.any(|existing| gitattributes_line_matches(existing, line))
})
.map(String::as_str)
.collect();
if missing.is_empty() {
return;
}
let label = if missing.len() == 1 {
"Next — add this line to .gitattributes:"
} else {
"Next — add these lines to .gitattributes:"
};
details.push(String::new());
details.push(label.into());
for line in &missing {
details.push(format!(" {line}"));
}
details.push(String::new());
details
.push("Then commit: git add .gitattributes && git commit -m 'enable plist filters'".into());
}
fn gitattributes_line_matches(existing: &str, expected: &str) -> bool {
let strip = |s: &str| -> Option<String> {
let trimmed = s.split('#').next().unwrap_or("").trim();
let mut parts = trimmed.split_ascii_whitespace();
let pattern = parts.next()?.to_string();
let binds_filter = parts.any(|tok| tok == "filter=dodot-plist");
if binds_filter {
Some(pattern)
} else {
None
}
};
match (strip(existing), strip(expected)) {
(Some(a), Some(b)) => a == b,
_ => false,
}
}
fn filter_is_installed(
runner: &dyn crate::datastore::CommandRunner,
root: &std::path::Path,
) -> Result<bool> {
match runner.run(
"git",
&[
"-C".into(),
root.display().to_string(),
"config".into(),
"--get".into(),
"filter.dodot-plist.clean".into(),
],
) {
Ok(out) => Ok(out.exit_code == 0 && !out.stdout.trim().is_empty()),
Err(DodotError::CommandFailed { exit_code: 1, .. }) => Ok(false),
Err(DodotError::CommandFailed { stderr, .. })
if stderr.contains("not a git repository") =>
{
Ok(false)
}
Err(e) => Err(e),
}
}
fn git_config_set(
runner: &dyn crate::datastore::CommandRunner,
root: &std::path::Path,
key: &str,
value: &str,
) -> Result<()> {
let out = runner.run(
"git",
&[
"-C".into(),
root.display().to_string(),
"config".into(),
key.into(),
value.into(),
],
)?;
if out.exit_code != 0 {
return Err(DodotError::CommandFailed {
command: format!("git -C {} config {} {}", root.display(), key, value),
exit_code: out.exit_code,
stderr: out.stderr,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_block_has_required_key() {
let block = config_block_text();
assert!(block.contains("[filter \"dodot-plist\"]"));
assert!(block.contains("clean = dodot plist clean"));
assert!(block.contains("smudge = dodot plist smudge"));
assert!(block.contains("required = true"));
}
fn make_test_ctx(env: &crate::testing::TempEnvironment) -> ExecutionContext {
use crate::config::ConfigManager;
use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
use crate::fs::Fs;
use crate::paths::Pather;
use std::sync::Arc;
struct NoopRunner;
impl CommandRunner for NoopRunner {
fn run(&self, _e: &str, _a: &[String]) -> Result<CommandOutput> {
Ok(CommandOutput {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
})
}
}
let runner: Arc<dyn CommandRunner> = Arc::new(NoopRunner);
let datastore = Arc::new(FilesystemDataStore::new(
env.fs.clone(),
env.paths.clone(),
runner.clone(),
));
let config_manager = Arc::new(ConfigManager::new(&env.dotfiles_root).unwrap());
ExecutionContext {
fs: env.fs.clone() as Arc<dyn Fs>,
datastore,
paths: env.paths.clone() as Arc<dyn Pather>,
config_manager,
syntax_checker: Arc::new(crate::shell::NoopSyntaxChecker),
command_runner: runner,
dry_run: false,
no_provision: true,
provision_rerun: false,
force: false,
view_mode: crate::commands::ViewMode::Full,
group_mode: crate::commands::GroupMode::Name,
verbose: false,
}
}
#[test]
fn detect_plist_files_finds_plists_in_packs() {
use crate::testing::TempEnvironment;
let env = TempEnvironment::builder()
.pack("mac-defaults")
.file("com.app.plist", "binary-or-xml")
.file("README.md", "no plist")
.done()
.pack("nvim")
.file("init.lua", "no plist")
.done()
.pack("system-prefs")
.file("nested/com.other.plist", "deeper")
.done()
.build();
let ctx = make_test_ctx(&env);
let found = detect_plist_files(&ctx).expect("detect");
assert_eq!(found.len(), 2, "expected 2 plists, got: {found:?}");
let names: Vec<String> = found
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert!(names.contains(&"com.app.plist".to_string()));
assert!(names.contains(&"com.other.plist".to_string()));
}
#[test]
fn detect_plist_files_skips_dodotignored_packs() {
use crate::testing::TempEnvironment;
let env = TempEnvironment::builder()
.pack("active")
.file("a.plist", "in active")
.done()
.pack("muted")
.file("b.plist", "in muted")
.ignored()
.done()
.build();
let ctx = make_test_ctx(&env);
let found = detect_plist_files(&ctx).expect("detect");
let names: Vec<String> = found
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert!(
names.contains(&"a.plist".to_string()),
"active pack's plist should be found"
);
assert!(
!names.contains(&"b.plist".to_string()),
".dodotignore'd pack's plist should be excluded, got: {names:?}"
);
}
#[test]
fn gitattributes_recogniser_handles_whitespace_and_comments() {
let expected = "*.plist filter=dodot-plist";
assert!(gitattributes_line_matches(
"*.plist filter=dodot-plist",
expected
));
assert!(gitattributes_line_matches(
" *.plist filter=dodot-plist ",
expected
));
assert!(gitattributes_line_matches(
"*.plist filter=dodot-plist diff=plist",
expected
));
assert!(gitattributes_line_matches(
"*.plist filter=dodot-plist # plist filter",
expected
));
assert!(!gitattributes_line_matches("", expected));
assert!(!gitattributes_line_matches("# commented out", expected));
assert!(!gitattributes_line_matches(
"*.plist filter=other",
expected
));
assert!(!gitattributes_line_matches(
"*.txt filter=dodot-plist",
expected
));
}
#[test]
fn gitattributes_lines_emits_one_per_extension() {
let lines = gitattributes_lines(&["plist".to_string()]);
assert_eq!(lines, vec!["*.plist filter=dodot-plist"]);
let lines = gitattributes_lines(&[
"plist".to_string(),
"binplist".to_string(),
"savedState".to_string(),
]);
assert_eq!(
lines,
vec![
"*.plist filter=dodot-plist",
"*.binplist filter=dodot-plist",
"*.savedState filter=dodot-plist",
]
);
}
#[test]
fn detect_plist_files_honors_custom_extension() {
use crate::testing::TempEnvironment;
let env = TempEnvironment::builder()
.pack("apps")
.file("com.app.plist", "binary-or-xml")
.file("com.other.binplist", "different ext")
.file("README.md", "should be ignored")
.config("[symlink]\nplist_extensions = [\"plist\", \"binplist\"]\n")
.done()
.build();
let ctx = make_test_ctx(&env);
let found = detect_plist_files(&ctx).expect("detect");
let names: Vec<String> = found
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert!(
names.contains(&"com.app.plist".to_string()),
"default-extension plist should be found: {names:?}"
);
assert!(
names.contains(&"com.other.binplist".to_string()),
"custom-extension plist should be found: {names:?}"
);
assert!(
!names.iter().any(|n| n.ends_with(".md")),
"non-plist files must not surface: {names:?}"
);
}
#[test]
fn normalize_plist_extensions_strips_lowercases_dedupes_and_filters() {
assert_eq!(
normalize_plist_extensions(&[
"plist".into(),
".plist".into(),
" .Plist ".into(),
"BinPlist".into(),
]),
vec!["plist".to_string(), "binplist".to_string()],
"leading dot, mixed case, and whitespace must collapse \
to a single canonical entry"
);
assert!(normalize_plist_extensions(&["".into(), " ".into(), ".".into()]).is_empty());
let dangerous = [
"evil; rm -rf".to_string(),
"*.txt".to_string(),
"weird path".to_string(),
"foo/bar".to_string(),
"quote'd".to_string(),
"back\\slash".to_string(),
"with\nnewline".to_string(),
];
assert!(
normalize_plist_extensions(&dangerous).is_empty(),
"metacharacter-bearing entries must be dropped"
);
assert_eq!(
normalize_plist_extensions(&[
"plist".into(),
"binplist".into(),
"savedState".into(),
"mobileconfig".into(),
]),
vec![
"plist".to_string(),
"binplist".to_string(),
"savedstate".to_string(),
"mobileconfig".to_string(),
]
);
}
}