mod marker;
mod trim;
mod units;
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::SystemTime;
use anyhow::{Context, Result, bail};
use clap::Subcommand;
pub fn saved_last_use(root: &Path) -> BTreeMap<PathBuf, SystemTime> {
marker::load_saved(root)
}
#[derive(Debug, clap::Args)]
pub struct Args {
#[command(subcommand)]
command: CiCommand,
}
#[derive(Debug, Subcommand)]
enum CiCommand {
Start {
#[arg(long, value_name = "DIR")]
target_dir: Option<PathBuf>,
},
Trim(TrimArgs),
}
#[derive(Debug, clap::Args)]
struct TrimArgs {
#[arg(long, value_name = "DIR")]
target_dir: Option<PathBuf>,
#[arg(long, value_name = "SIZE")]
budget: Option<String>,
#[arg(long)]
keep_workspace: bool,
#[arg(long)]
keep_incremental: bool,
#[arg(long = "keep-package", value_name = "PACKAGE")]
keep_packages: Vec<String>,
#[arg(long, value_enum, default_value = "rebuild-cost")]
eviction: trim::Eviction,
#[arg(long)]
dry_run: bool,
#[arg(short, long)]
verbose: bool,
}
pub fn run(args: &Args) -> Result<()> {
match &args.command {
CiCommand::Start { target_dir } => {
let root = match target_dir {
Some(dir) => dir.clone(),
None => metadata()?.build_dir,
};
start(&root, marker::probe_atime)
}
CiCommand::Trim(args) => {
let budget = args
.budget
.as_deref()
.filter(|b| !b.trim().is_empty())
.map(|b| crate::config::parse_size(b).with_context(|| format!("--budget {b:?}")))
.transpose()?;
let (root, members) = match &args.target_dir {
Some(dir) => (dir.clone(), metadata().ok().map(|m| m.members)),
None => {
let meta = metadata()?;
(meta.build_dir, Some(meta.members))
}
};
if members.is_none() && !args.keep_workspace {
eprintln!(
"warning: cargo-steward: `cargo metadata` failed here, so the workspace's \
own crates can't be told apart and are trimmed like the rest"
);
}
trim::run(&trim::Options {
root,
members: members.unwrap_or_default(),
budget,
keep_workspace: args.keep_workspace,
keep_incremental: args.keep_incremental,
keep_packages: args.keep_packages.iter().cloned().collect(),
eviction: args.eviction,
dry_run: args.dry_run,
verbose: args.verbose,
step_summary: env::var_os("GITHUB_STEP_SUMMARY")
.filter(|v| !v.is_empty())
.map(PathBuf::from),
})
}
}
}
fn start(root: &Path, probe: fn(&Path) -> std::io::Result<bool>) -> Result<()> {
if !root.is_dir() {
println!(
"cargo-steward: {} doesn't exist yet, so everything the job builds there counts as used",
root.display()
);
return Ok(());
}
if root.join(marker::SAVED_FILE).exists() {
marker::restore_all(root)
.with_context(|| format!("restoring access times in {}", root.display()))?;
}
let start = marker::now_secs();
let atime =
probe(root).with_context(|| format!("checking access times in {}", root.display()))?;
let reset = if atime {
marker::reset_atimes(root)
.with_context(|| format!("saving access times in {}", root.display()))?
} else {
0
};
marker::write(root, &marker::Marker { start, atime })?;
println!(
"cargo-steward: marked the job's start in {}",
root.display()
);
if atime {
println!(" {reset} fingerprint files will show whether this job uses their unit");
} else {
println!(
" this filesystem doesn't record reads (noatime), so `ci trim` can't tell which \
units the job used and only applies --budget"
);
}
Ok(())
}
struct Metadata {
build_dir: PathBuf,
members: BTreeSet<String>,
}
fn metadata() -> Result<Metadata> {
let cargo = env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"));
let out = Command::new(&cargo)
.args(["metadata", "--no-deps", "--format-version", "1"])
.output()
.with_context(|| format!("running {} metadata", cargo.to_string_lossy()))?;
if !out.status.success() {
bail!(
"`cargo metadata` failed (pass --target-dir to name the build directory):\n{}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
parse_metadata(&out.stdout)
}
fn parse_metadata(json: &[u8]) -> Result<Metadata> {
let value: serde_json::Value =
serde_json::from_slice(json).context("reading `cargo metadata` output")?;
let build_dir = ["build_directory", "target_directory"]
.iter()
.find_map(|key| value.get(key).and_then(|v| v.as_str()))
.map(PathBuf::from)
.context("`cargo metadata` reported no target directory")?;
let members = value
.get("packages")
.and_then(|p| p.as_array())
.into_iter()
.flatten()
.filter_map(|p| p.get("name").and_then(|n| n.as_str()))
.map(str::to_owned)
.collect();
Ok(Metadata { build_dir, members })
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::{Duration, UNIX_EPOCH};
#[test]
fn access_times_survive_a_job_that_never_trims() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("target");
let dir = root.join("debug/.fingerprint/serde-0123456789abcdef");
let file = dir.join("lib-serde");
fs::create_dir_all(&dir).unwrap();
fs::write(&file, b"x").unwrap();
let built = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let read = UNIX_EPOCH + Duration::from_secs(1_750_000_000);
fs::File::open(&file)
.unwrap()
.set_times(fs::FileTimes::new().set_modified(built).set_accessed(read))
.unwrap();
let accessed = || fs::metadata(&file).unwrap().accessed().unwrap();
start(&root, marker::probe_atime).unwrap();
if !marker::read(&root).unwrap().unwrap().atime {
return; }
assert_eq!(accessed(), UNIX_EPOCH);
assert_eq!(saved_last_use(&root).get(&dir), Some(&read));
start(&root, marker::probe_atime).unwrap();
assert_eq!(accessed(), UNIX_EPOCH);
assert_eq!(saved_last_use(&root).get(&dir), Some(&read));
marker::write(
&root,
&marker::Marker {
start: 1_600_000_000,
atime: true,
},
)
.unwrap();
trim::run(&trim::Options {
root: root.clone(),
members: BTreeSet::new(),
budget: None,
keep_workspace: true,
keep_incremental: true,
keep_packages: BTreeSet::new(),
eviction: trim::Eviction::default(),
dry_run: false,
verbose: false,
step_summary: None,
})
.unwrap();
assert_eq!(accessed(), read);
assert!(saved_last_use(&root).is_empty());
assert_eq!(marker::read(&root).unwrap(), None);
}
#[test]
fn start_leaves_creating_the_build_directory_to_cargo() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("target");
start(&root, marker::probe_atime).unwrap();
assert!(
!root.exists(),
"cargo only tags a build directory it creates"
);
}
#[test]
fn reads_the_build_directory_and_members_from_metadata() {
let json = br#"{"packages":[{"name":"app"},{"name":"app-core"}],
"target_directory":"/w/target","build_directory":"/w/build","version":1}"#;
let meta = parse_metadata(json).unwrap();
assert_eq!(meta.build_dir, PathBuf::from("/w/build"));
assert_eq!(
meta.members,
BTreeSet::from(["app".to_owned(), "app-core".to_owned()])
);
let old = br#"{"packages":[],"target_directory":"/w/target"}"#;
assert_eq!(
parse_metadata(old).unwrap().build_dir,
PathBuf::from("/w/target")
);
assert!(parse_metadata(b"{}").is_err());
}
#[test]
fn start_on_a_noatime_mount_records_budget_only() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("target");
std::fs::create_dir_all(&root).unwrap();
start(&root, |_| Ok(false)).unwrap();
let marker = marker::read(&root).unwrap().unwrap();
assert!(!marker.atime);
start(&root, marker::probe_atime).unwrap();
assert!(marker::read(&root).unwrap().is_some());
}
}