use std::env;
use std::fs;
use std::io;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct Paths {
pub home: PathBuf,
}
impl Paths {
pub fn resolve() -> Option<Paths> {
if let Some(dir) = env::var_os("CARGO_STEWARD_HOME").filter(|v| !v.is_empty()) {
return Some(Paths { home: dir.into() });
}
Some(Paths {
home: cargo_home()?.join("steward"),
})
}
pub fn config(&self) -> PathBuf {
self.home.join("config.toml")
}
pub fn ledger(&self) -> PathBuf {
self.home.join("ledger.jsonl")
}
pub fn registry(&self) -> PathBuf {
self.home.join("registry.json")
}
pub fn stamp(&self) -> PathBuf {
self.home.join("gc.stamp")
}
pub fn gc_backoff(&self) -> PathBuf {
self.home.join("gc.backoff")
}
pub fn gc_lock(&self) -> PathBuf {
self.home.join("gc.lock")
}
pub fn dedup_stamp(&self) -> PathBuf {
self.home.join("dedup.stamp")
}
pub fn log(&self) -> PathBuf {
self.home.join("gc.log")
}
pub fn compile_slots(&self) -> PathBuf {
self.home.join("compile-slots")
}
}
pub fn cargo_home() -> Option<PathBuf> {
env::var_os("CARGO_HOME")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".cargo")))
}
pub fn user_cargo_config(cargo_home: &Path) -> PathBuf {
let toml = cargo_home.join("config.toml");
let legacy = cargo_home.join("config");
if legacy.exists() && !crate::fsutil::same_file(&legacy, &toml) {
legacy
} else {
toml
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct Config {
pub inner_wrapper: Option<String>,
pub leak_fix: bool,
pub auto_gc: bool,
pub gc_interval: String,
pub scan_roots: Vec<PathBuf>,
pub unit_idle: String,
pub incremental_idle: String,
pub legacy_idle: String,
pub dir_idle: String,
pub orphan_grace: String,
pub recent_guard: String,
pub min_free: String,
pub target_free: String,
pub managed_build_dir: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_wrapper: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_build_dir: Option<String>,
pub object_grace: String,
pub process_guard: bool,
pub max_compiles: Option<u32>,
pub compile_memory_budget: Option<String>,
pub compile_memory_estimate: String,
pub record_costs: bool,
pub seed: bool,
pub dedup: bool,
pub target_root: Option<PathBuf>,
pub tmp_root: Option<PathBuf>,
pub max_builds: Option<u32>,
pub real_cargo: Option<PathBuf>,
pub link_target: bool,
pub routes: Vec<Route>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Route {
pub path: PathBuf,
#[serde(default)]
pub target_root: Option<PathBuf>,
#[serde(default)]
pub tmp_root: Option<PathBuf>,
#[serde(default)]
pub max_builds: Option<u32>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub force: bool,
}
impl Default for Config {
fn default() -> Config {
Config {
inner_wrapper: None,
leak_fix: true,
auto_gc: false,
gc_interval: "1h".into(),
scan_roots: Vec::new(),
unit_idle: "14d".into(),
incremental_idle: "7d".into(),
legacy_idle: "3d".into(),
dir_idle: "21d".into(),
orphan_grace: "2d".into(),
recent_guard: "1d".into(),
min_free: "50GiB".into(),
target_free: "100GiB".into(),
managed_build_dir: false,
previous_wrapper: None,
previous_build_dir: None,
object_grace: "10m".into(),
process_guard: true,
max_compiles: None,
compile_memory_budget: None,
compile_memory_estimate: "2GiB".into(),
record_costs: true,
seed: true,
dedup: true,
target_root: None,
tmp_root: None,
max_builds: None,
real_cargo: None,
routes: Vec::new(),
link_target: false,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Policy {
pub gc_interval: Duration,
pub unit_idle: Duration,
pub incremental_idle: Duration,
pub legacy_idle: Duration,
pub dir_idle: Duration,
pub orphan_grace: Duration,
pub recent_guard: Duration,
pub min_free: u64,
pub target_free: u64,
pub object_grace: Duration,
}
impl Config {
pub fn policy(&self) -> Result<Policy> {
let duration = |key: &str, value: &str| {
humantime::parse_duration(value.trim()).with_context(|| format!("{key} = {value:?}"))
};
let size = |key: &str, value: &str| {
parse_size(value).with_context(|| format!("{key} = {value:?}"))
};
let policy = Policy {
gc_interval: duration("gc-interval", &self.gc_interval)?,
unit_idle: duration("unit-idle", &self.unit_idle)?,
incremental_idle: duration("incremental-idle", &self.incremental_idle)?,
legacy_idle: duration("legacy-idle", &self.legacy_idle)?,
dir_idle: duration("dir-idle", &self.dir_idle)?,
orphan_grace: duration("orphan-grace", &self.orphan_grace)?,
recent_guard: duration("recent-guard", &self.recent_guard)?,
min_free: size("min-free", &self.min_free)?,
target_free: size("target-free", &self.target_free)?,
object_grace: duration("object-grace", &self.object_grace)?,
};
if policy.target_free < policy.min_free {
bail!("target-free must be at least min-free");
}
if size("compile-memory-estimate", &self.compile_memory_estimate)? == 0 {
bail!("compile-memory-estimate must be positive");
}
if let Some(budget) = &self.compile_memory_budget
&& size("compile-memory-budget", budget)? == 0
{
bail!("compile-memory-budget must be positive");
}
Ok(policy)
}
}
pub fn load(paths: &Paths) -> Result<Config> {
let path = paths.config();
match fs::read_to_string(&path) {
Ok(text) => toml::from_str(&text).with_context(|| format!("reading {}", path.display())),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Config::default()),
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
}
}
pub fn load_lenient(paths: &Paths) -> (Config, Vec<String>) {
let path = paths.config();
match fs::read_to_string(&path) {
Ok(text) => lenient(&text, &path),
Err(e) if e.kind() == io::ErrorKind::NotFound => (Config::default(), Vec::new()),
Err(e) => (
Config::default(),
vec![format!(
"reading {}: {e}; using the defaults",
path.display()
)],
),
}
}
fn parse_table(table: &toml::Table) -> std::result::Result<Config, String> {
let config: Config = toml::Value::Table(table.clone())
.try_into()
.map_err(|e: toml::de::Error| e.message().to_owned())?;
config.policy().map_err(|e| format!("{e:#}"))?;
Ok(config)
}
fn lenient(text: &str, path: &std::path::Path) -> (Config, Vec<String>) {
let table: toml::Table = match text.parse() {
Ok(table) => table,
Err(e) => {
return (
Config::default(),
vec![format!(
"{} isn't valid TOML ({}); using the defaults",
path.display(),
e.message()
)],
);
}
};
let mut accepted = toml::Table::new();
let mut config = Config::default();
let mut pending: Vec<(String, toml::Value, String)> = table
.into_iter()
.map(|(key, value)| (key, value, String::new()))
.collect();
loop {
let before = pending.len();
pending.retain_mut(|(key, value, error)| {
let mut candidate = accepted.clone();
candidate.insert(key.clone(), value.clone());
match parse_table(&candidate) {
Ok(parsed) => {
accepted = candidate;
config = parsed;
false
}
Err(e) => {
*error = e;
true
}
}
});
if pending.is_empty() || pending.len() == before {
break;
}
}
let mut problems: Vec<String> = pending
.into_iter()
.map(|(key, _, error)| format!("{key}: {error}; using the default"))
.collect();
let known: std::collections::BTreeSet<String> = toml::Value::try_from(&config)
.ok()
.and_then(|v| v.as_table().map(|t| t.keys().cloned().collect()))
.unwrap_or_default();
for key in accepted.keys().filter(|k| !known.contains(*k)) {
let mut without = accepted.clone();
without.remove(key);
if parse_table(&without).is_ok_and(|c| c == config) {
problems.push(format!("{key}: not a cargo-steward setting; ignored"));
}
}
(config, problems)
}
pub fn load_warning(paths: &Paths) -> Config {
let (config, problems) = load_lenient(paths);
for problem in problems {
eprintln!("warning: cargo-steward: settings: {problem}");
}
config
}
pub fn save(paths: &Paths, config: &Config) -> Result<()> {
fs::create_dir_all(&paths.home)
.with_context(|| format!("creating {}", paths.home.display()))?;
let text = toml::to_string_pretty(config)?;
write_atomic(&paths.config(), text.as_bytes())
}
pub fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
let path = resolve_links(path);
let permissions = fs::metadata(&path)
.ok()
.filter(fs::Metadata::is_file)
.map(|m| m.permissions());
let tmp = path.with_extension(format!("tmp{}", std::process::id()));
if let Err(e) = write_with_permissions(&tmp, contents, permissions.as_ref()) {
let _ = fs::remove_file(&tmp);
return Err(e).with_context(|| format!("writing {}", tmp.display()));
}
if let Err(e) = fs::rename(&tmp, &path) {
let _ = fs::remove_file(&tmp);
return Err(e).with_context(|| format!("writing {}", path.display()));
}
Ok(())
}
pub fn write_with_permissions(
path: &Path,
contents: &[u8],
permissions: Option<&fs::Permissions>,
) -> io::Result<()> {
let mut options = fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
if let Some(permissions) = permissions {
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
options.mode(permissions.mode());
}
let mut file = options.open(path)?;
file.write_all(contents)?;
if let Some(permissions) = permissions {
file.set_permissions(permissions.clone())?;
}
Ok(())
}
fn resolve_links(path: &Path) -> PathBuf {
let mut path = path.to_path_buf();
for _ in 0..40 {
let Ok(target) = fs::read_link(&path) else {
break;
};
path = match path.parent() {
Some(dir) => dir.join(target),
None => target,
};
}
path
}
pub fn parse_size(text: &str) -> Result<u64> {
let text = text.trim();
let split = text
.find(|c: char| !(c.is_ascii_digit() || c == '.'))
.unwrap_or(text.len());
let (number, unit) = text.split_at(split);
let value: f64 = number.parse().context("expected a number")?;
let multiplier: u64 = match unit.trim().to_ascii_lowercase().as_str() {
"" | "b" => 1,
"k" | "kb" | "kib" => 1 << 10,
"m" | "mb" | "mib" => 1 << 20,
"g" | "gb" | "gib" => 1 << 30,
"t" | "tb" | "tib" => 1 << 40,
other => bail!("unknown unit {other:?}"),
};
Ok((value * multiplier as f64) as u64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_sizes() {
assert_eq!(parse_size("1024").unwrap(), 1024);
assert_eq!(parse_size("50GiB").unwrap(), 50 << 30);
assert_eq!(parse_size(" 1.5 G ").unwrap(), 3 << 29);
assert_eq!(parse_size("2tb").unwrap(), 2 << 40);
assert!(parse_size("ten").is_err());
assert!(parse_size("5 parsecs").is_err());
}
#[test]
fn defaults_parse_and_round_trip() {
let config = Config::default();
config.policy().unwrap();
let text = toml::to_string_pretty(&config).unwrap();
assert_eq!(toml::from_str::<Config>(&text).unwrap(), config);
}
#[test]
fn partial_files_keep_the_other_defaults() {
let config: Config = toml::from_str("auto-gc = true\ndir-idle = \"7d\"\n").unwrap();
assert!(config.auto_gc);
assert_eq!(config.dir_idle, "7d");
assert_eq!(config.unit_idle, Config::default().unit_idle);
}
#[test]
fn rejects_inverted_free_space_thresholds() {
let config = Config {
min_free: "100G".into(),
target_free: "50G".into(),
..Config::default()
};
assert!(config.policy().is_err());
}
#[test]
fn keeps_every_file_in_the_home_directory() {
let paths = Paths {
home: PathBuf::from("/s"),
};
assert_eq!(paths.config(), PathBuf::from("/s/config.toml"));
assert_eq!(paths.ledger(), PathBuf::from("/s/ledger.jsonl"));
assert_eq!(paths.registry(), PathBuf::from("/s/registry.json"));
assert_eq!(paths.stamp(), PathBuf::from("/s/gc.stamp"));
assert_eq!(paths.gc_lock(), PathBuf::from("/s/gc.lock"));
assert_eq!(paths.log(), PathBuf::from("/s/gc.log"));
assert_eq!(paths.compile_slots(), PathBuf::from("/s/compile-slots"));
}
#[test]
fn saves_loads_and_reports_unreadable_settings() {
let tmp = tempfile::tempdir().unwrap();
let paths = Paths {
home: tmp.path().join("steward"),
};
assert_eq!(load(&paths).unwrap(), Config::default());
let config = Config {
auto_gc: true,
scan_roots: vec!["/v".into()],
..Config::default()
};
save(&paths, &config).unwrap();
assert_eq!(load(&paths).unwrap(), config);
let names: Vec<_> = fs::read_dir(&paths.home).unwrap().flatten().collect();
assert_eq!(names.len(), 1);
fs::write(paths.config(), "auto-gc = \"yes\"\n").unwrap();
let err = format!("{:#}", load(&paths).unwrap_err());
assert!(err.contains("reading"), "{err}");
fs::remove_file(paths.config()).unwrap();
fs::create_dir(paths.config()).unwrap();
assert!(load(&paths).is_err(), "a directory isn't a missing file");
}
#[test]
fn names_the_setting_that_doesnt_parse() {
let config = Config {
dir_idle: "soon".into(),
..Config::default()
};
let err = format!("{:#}", config.policy().unwrap_err());
assert!(err.contains("dir-idle = \"soon\""), "{err}");
let config = Config {
min_free: "lots".into(),
..Config::default()
};
let err = format!("{:#}", config.policy().unwrap_err());
assert!(err.contains("min-free"), "{err}");
let bad = || "never".to_string();
type Set = fn(&mut Config, String);
let cases: [(&str, Set); 8] = [
("gc-interval", |c, v| c.gc_interval = v),
("unit-idle", |c, v| c.unit_idle = v),
("incremental-idle", |c, v| c.incremental_idle = v),
("legacy-idle", |c, v| c.legacy_idle = v),
("orphan-grace", |c, v| c.orphan_grace = v),
("recent-guard", |c, v| c.recent_guard = v),
("target-free", |c, v| c.target_free = v),
("object-grace", |c, v| c.object_grace = v),
];
for (key, set) in cases {
let mut config = Config::default();
set(&mut config, bad());
let err = format!("{:#}", config.policy().unwrap_err());
assert!(err.contains(&format!("{key} = \"never\"")), "{err}");
}
}
#[test]
fn parses_every_unit() {
assert_eq!(parse_size("3b").unwrap(), 3);
assert_eq!(parse_size("2k").unwrap(), 2 << 10);
assert_eq!(parse_size("2KiB").unwrap(), 2 << 10);
assert_eq!(parse_size("5mb").unwrap(), 5 << 20);
assert_eq!(parse_size("1t").unwrap(), 1 << 40);
}
#[test]
fn reports_settings_that_cant_be_written() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("file");
fs::write(&file, "x").unwrap();
let err = format!(
"{:#}",
save(&Paths { home: file.clone() }, &Config::default()).unwrap_err()
);
assert!(err.contains("creating"), "{err}");
let err = format!(
"{:#}",
write_atomic(&tmp.path().join("missing/config.toml"), b"x").unwrap_err()
);
assert!(err.contains("writing"), "{err}");
let dir = tmp.path().join("config.toml");
fs::create_dir_all(dir.join("inside")).unwrap();
let err = format!("{:#}", write_atomic(&dir, b"x").unwrap_err());
assert!(err.contains(&format!("writing {}", dir.display())), "{err}");
assert!(dir.join("inside").is_dir());
let names: Vec<_> = fs::read_dir(tmp.path()).unwrap().flatten().collect();
assert_eq!(names.len(), 2, "{names:?}");
}
fn lenient_str(text: &str) -> (Config, Vec<String>) {
lenient(text, std::path::Path::new("config.toml"))
}
#[test]
fn a_bad_setting_falls_back_alone() {
let (config, problems) = lenient_str(
"inner-wrapper = \"sccache\"\nauto-gc = \"yes\"\ndir-idle = \"soon\"\n\
unit-idle = \"3d\"\n",
);
assert_eq!(config.inner_wrapper.as_deref(), Some("sccache"));
assert_eq!(config.unit_idle, "3d");
assert!(!config.auto_gc, "the default");
assert_eq!(config.dir_idle, Config::default().dir_idle);
assert_eq!(problems.len(), 2, "{problems:?}");
assert!(problems.iter().any(|p| p.starts_with("auto-gc:")));
assert!(problems.iter().any(|p| p.starts_with("dir-idle:")));
}
#[test]
fn settings_that_only_fit_together_are_both_kept() {
let (config, problems) = lenient_str("target-free = \"300G\"\nmin-free = \"200G\"\n");
assert!(problems.is_empty(), "{problems:?}");
assert_eq!(config.min_free, "200G");
assert_eq!(config.target_free, "300G");
}
#[test]
fn unknown_settings_and_broken_files_are_reported() {
let (config, problems) = lenient_str("auto-gc = true\nmax_builds = 3\n");
assert!(config.auto_gc);
assert_eq!(config.max_builds, None);
assert_eq!(
problems,
["max_builds: not a cargo-steward setting; ignored"]
);
assert!(lenient_str("leak-fix = true\n").1.is_empty());
let (config, problems) = lenient_str("auto-gc = \n");
assert_eq!(config, Config::default());
assert_eq!(problems.len(), 1);
assert!(problems[0].contains("isn't valid TOML"), "{problems:?}");
let tmp = tempfile::tempdir().unwrap();
let paths = Paths {
home: tmp.path().to_path_buf(),
};
assert_eq!(load_lenient(&paths), (Config::default(), Vec::new()));
}
#[test]
fn the_compile_limit_round_trips_and_loads_leniently() {
let config = Config {
max_compiles: Some(4),
..Config::default()
};
let text = toml::to_string_pretty(&config).unwrap();
assert!(text.contains("max-compiles = 4"), "{text}");
assert_eq!(toml::from_str::<Config>(&text).unwrap(), config);
assert_eq!(Config::default().max_compiles, None);
let (config, problems) = lenient_str("max-compiles = 2\n");
assert!(problems.is_empty(), "{problems:?}");
assert_eq!(config.max_compiles, Some(2));
let (config, problems) = lenient_str("max-compiles = 0\n");
assert!(problems.is_empty(), "{problems:?}");
assert_eq!(config.max_compiles, Some(0));
let (config, problems) = lenient_str("max-compiles = -1\nauto-gc = true\n");
assert_eq!(config.max_compiles, None);
assert!(config.auto_gc);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].starts_with("max-compiles:"), "{problems:?}");
}
#[cfg(unix)]
#[test]
fn writes_through_links_and_keeps_permissions() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let mode = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o777;
let dotfiles = tmp.path().join("dotfiles");
fs::create_dir_all(&dotfiles).unwrap();
let real = dotfiles.join("cargo.toml");
fs::write(&real, "old").unwrap();
fs::set_permissions(&real, fs::Permissions::from_mode(0o600)).unwrap();
let home = tmp.path().join("home");
fs::create_dir_all(&home).unwrap();
let link = home.join("config.toml");
std::os::unix::fs::symlink("../dotfiles/cargo.toml", &link).unwrap();
write_atomic(&link, b"new").unwrap();
assert!(fs::symlink_metadata(&link).unwrap().is_symlink());
assert_eq!(fs::read_to_string(&real).unwrap(), "new");
assert_eq!(mode(&real), 0o600);
assert_eq!(fs::read_dir(&home).unwrap().count(), 1);
assert_eq!(fs::read_dir(&dotfiles).unwrap().count(), 1);
let dangling = home.join("other.toml");
std::os::unix::fs::symlink(dotfiles.join("new.toml"), &dangling).unwrap();
write_atomic(&dangling, b"x").unwrap();
assert!(fs::symlink_metadata(&dangling).unwrap().is_symlink());
assert_eq!(fs::read_to_string(dotfiles.join("new.toml")).unwrap(), "x");
let private = tmp.path().join("private");
write_with_permissions(&private, b"x", Some(&fs::Permissions::from_mode(0o600))).unwrap();
assert_eq!(mode(&private), 0o600);
}
#[test]
fn records_of_what_setup_replaced_are_optional() {
let config: Config = toml::from_str("managed-build-dir = true\n").unwrap();
assert_eq!(config.previous_wrapper, None);
assert_eq!(config.previous_build_dir, None);
let text = toml::to_string_pretty(&Config::default()).unwrap();
assert!(!text.contains("previous"), "{text}");
let config = Config {
previous_wrapper: Some(String::new()),
previous_build_dir: Some("/b/{workspace-path-hash}".into()),
..Config::default()
};
let text = toml::to_string_pretty(&config).unwrap();
assert!(text.contains("previous-wrapper = \"\""), "{text}");
assert!(text.contains("previous-build-dir = "), "{text}");
assert_eq!(toml::from_str::<Config>(&text).unwrap(), config);
}
#[test]
fn finds_the_user_cargo_config_cargo_reads() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
assert_eq!(user_cargo_config(home), home.join("config.toml"));
fs::write(home.join("config.toml"), "").unwrap();
assert_eq!(user_cargo_config(home), home.join("config.toml"));
fs::write(home.join("config"), "").unwrap();
assert_eq!(user_cargo_config(home), home.join("config"));
fs::remove_file(home.join("config.toml")).unwrap();
assert_eq!(user_cargo_config(home), home.join("config"));
#[cfg(unix)]
{
fs::remove_file(home.join("config")).unwrap();
fs::write(home.join("config.toml"), "").unwrap();
std::os::unix::fs::symlink("config.toml", home.join("config")).unwrap();
assert_eq!(user_cargo_config(home), home.join("config.toml"));
}
}
}