use std::path::{Path, PathBuf};
use std::process::Command;
use super::{Level, Report, cmd};
use crate::setup::shell_quote;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Exclusion {
pub path: PathBuf,
pub excluded: bool,
}
pub fn parse_isexcluded(text: &str) -> Vec<Exclusion> {
text.lines()
.filter_map(|line| {
let line = line.trim();
let (excluded, rest) = match line.strip_prefix("[Excluded]") {
Some(rest) => (true, rest),
None => (false, line.strip_prefix("[Included]")?),
};
Some(Exclusion {
path: PathBuf::from(rest.trim()),
excluded,
})
})
.collect()
}
pub fn isexcluded(dirs: &[PathBuf]) -> Vec<Exclusion> {
if !cfg!(target_os = "macos") || dirs.is_empty() {
return Vec::new();
}
cmd::stdout(Command::new("tmutil").arg("isexcluded").args(dirs))
.map(|t| parse_isexcluded(&t))
.unwrap_or_default()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Indexing {
On,
Off,
Unknown,
}
#[derive(Debug, Clone)]
pub struct RootStatus {
pub root: PathBuf,
pub excluded: Option<bool>,
pub indexing: Indexing,
}
pub fn probe_roots(roots: &[PathBuf]) -> Vec<RootStatus> {
if !cfg!(target_os = "macos") {
return Vec::new();
}
let existing: Vec<PathBuf> = roots.iter().filter(|r| r.exists()).cloned().collect();
std::thread::scope(|s| {
let tm = s.spawn(|| isexcluded(&existing));
let md: Vec<_> = existing
.iter()
.map(|root| s.spawn(move || indexing(root)))
.collect();
let tm = tm.join().unwrap_or_default();
existing
.iter()
.zip(md)
.map(|(root, job)| RootStatus {
root: root.clone(),
excluded: tm.iter().find(|e| &e.path == root).map(|e| e.excluded),
indexing: job.join().unwrap_or(Indexing::Unknown),
})
.collect()
})
}
fn indexing(root: &Path) -> Indexing {
match cmd::stdout(Command::new("mdutil").arg("-s").arg(root)) {
Some(text) if text.contains("Indexing enabled") => Indexing::On,
Some(text) if text.contains("disabled") => Indexing::Off,
_ => Indexing::Unknown,
}
}
pub fn report_root(out: &mut Report, status: &RootStatus) {
for (level, message) in root_findings(status) {
out.say(level, message);
}
}
fn root_findings(status: &RootStatus) -> Vec<(Level, String)> {
let root = status.root.display();
let quoted = shell_quote(&status.root);
let mut findings = Vec::new();
match status.excluded {
Some(true) => findings.push((Level::Ok, format!("{root} is excluded from Time Machine"))),
Some(false) => findings.push((
Level::Warn,
format!("{root} is backed up by Time Machine.\nFix: tmutil addexclusion {quoted}"),
)),
None => {}
}
match status.indexing {
Indexing::On => findings.push((
Level::Warn,
format!(
"Spotlight indexes {root}, which costs CPU on every build.\nFix (volume roots): sudo mdutil -i off {quoted}"
),
)),
Indexing::Off => findings.push((Level::Ok, format!("Spotlight doesn't index {root}"))),
Indexing::Unknown => {}
}
findings
}
pub fn report_build_dirs(out: &mut Report, on_system_disk: usize, statuses: &[Exclusion]) {
if !cfg!(target_os = "macos") || on_system_disk == 0 {
return;
}
let included: Vec<&Exclusion> = statuses.iter().filter(|e| !e.excluded).collect();
out.say(
Level::Info,
format!(
"{on_system_disk} build directories are on the system disk, where local snapshots keep deleted files\n\
for about a day. A separate APFS volume avoids that: `cargo steward setup --volume NAME`."
),
);
if included.is_empty() {
if !statuses.is_empty() {
out.say(Level::Ok, "Time Machine excludes all of them");
}
return;
}
let list: Vec<String> = included
.iter()
.map(|e| e.path.display().to_string())
.collect();
let fixes: Vec<String> = included
.iter()
.map(|e| format!("tmutil addexclusion {}", shell_quote(&e.path)))
.collect();
out.say(
Level::Warn,
format!(
"Time Machine backs up {} of them (created before cargo could exclude them):\n{}\n\
Fix (a sticky exclusion; no sudo needed):\n{}",
included.len(),
list.join("\n"),
fixes.join("\n")
),
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quotes_paths_in_fix_commands() {
let status = RootStatus {
root: "/Volumes/Build Scratch".into(),
excluded: Some(false),
indexing: Indexing::On,
};
let text: Vec<String> = root_findings(&status).into_iter().map(|f| f.1).collect();
assert!(
text[0].ends_with("Fix: tmutil addexclusion '/Volumes/Build Scratch'"),
"{text:?}"
);
assert!(
text[1].ends_with("sudo mdutil -i off '/Volumes/Build Scratch'"),
"{text:?}"
);
}
#[test]
fn parses_tmutil_isexcluded() {
let text = "[Excluded] /Users/me/p/target\n\
[Included] /Users/me/q/build dir\n\
[UNKNOWN] /nonexistent\n";
assert_eq!(
parse_isexcluded(text),
[
Exclusion {
path: "/Users/me/p/target".into(),
excluded: true
},
Exclusion {
path: "/Users/me/q/build dir".into(),
excluded: false
},
]
);
}
}