use std::cell::OnceCell;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use anyhow::{Context, Result};
pub const ARCHIVE_THRESHOLD: u64 = 8;
pub fn should_archive(files: u64, no_archive: bool) -> bool {
!no_archive && files >= ARCHIVE_THRESHOLD
}
pub struct Plan {
pub dirs: Vec<String>,
pub entries: Vec<Entry>,
pub files: u64,
pub bytes: u64,
pub newest: Option<SystemTime>,
}
pub struct Entry {
pub path: PathBuf,
pub relative: String,
pub size: u64,
}
pub fn plan_upload(local_root: &Path) -> Result<Plan> {
let mut plan = Plan {
dirs: Vec::new(),
entries: Vec::new(),
files: 0,
bytes: 0,
newest: None,
};
let mut queue = vec![local_root.to_path_buf()];
while let Some(dir) = queue.pop() {
for entry in std::fs::read_dir(&dir).with_context(|| format!("cannot read {}", dir.display()))? {
let entry = entry?;
let path = entry.path();
let relative = path.strip_prefix(local_root).expect("entry under root").to_string_lossy().replace('\\', "/");
let metadata = entry.metadata().with_context(|| format!("cannot stat {}", path.display()))?;
if metadata.is_dir() {
plan.dirs.push(relative);
queue.push(path);
} else {
plan.files += 1;
plan.bytes += metadata.len();
if let Ok(modified) = metadata.modified() {
plan.newest = plan.newest.max(Some(modified));
}
plan.entries.push(Entry {
path,
relative,
size: metadata.len(),
});
}
}
}
plan.dirs.sort_by_key(|d| d.matches('/').count());
Ok(plan)
}
pub fn pack(plan: &Plan) -> Result<Vec<u8>> {
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut builder = tar::Builder::new(encoder);
for entry in &plan.entries {
let mut file = std::fs::File::open(&entry.path).with_context(|| format!("cannot open {}", entry.path.display()))?;
builder
.append_file(&entry.relative, &mut file)
.with_context(|| format!("cannot add {} to the archive", entry.path.display()))?;
}
let encoder = builder.into_inner().context("cannot finish the archive")?;
encoder.finish().context("cannot compress the archive")
}
pub struct Artifact {
pub app: String,
pub plan: Plan,
archive: OnceCell<Vec<u8>>,
}
impl Artifact {
pub fn new(app: &str, plan: Plan) -> Self {
Self {
app: app.to_string(),
plan,
archive: OnceCell::new(),
}
}
pub fn archive(&self) -> Result<&[u8]> {
if self.archive.get().is_none() {
let packed = pack(&self.plan)?;
let _ = self.archive.set(packed);
}
Ok(self.archive.get().expect("the archive was just packed"))
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct Stale {
pub source: String,
pub after: Duration,
}
const SLACK: Duration = Duration::from_secs(2);
const NOT_SOURCES: [&str; 5] = [".git", "node_modules", "target", ".turbo", ".cache"];
pub fn stale(project: &Path, dist: &Path, built: SystemTime, skip: &[&Path]) -> Option<Stale> {
let sources = git_sources(project).unwrap_or_else(|| walked_sources(project));
let dist = dist.strip_prefix(project).unwrap_or(dist);
let newest = sources
.into_iter()
.filter(|relative| !relative.starts_with(dist) && !skip.iter().any(|skip| relative == skip))
.filter_map(|relative| {
let modified = std::fs::metadata(project.join(&relative)).ok()?.modified().ok()?;
Some((modified, relative))
})
.max_by_key(|(modified, _)| *modified)?;
let after = newest.0.duration_since(built).ok().filter(|after| *after > SLACK)?;
Some(Stale {
source: newest.1.to_string_lossy().replace('\\', "/"),
after,
})
}
fn git_sources(project: &Path) -> Option<Vec<PathBuf>> {
let output = std::process::Command::new("git")
.args(["ls-files", "--cached", "--others", "--exclude-standard", "-z"])
.current_dir(project)
.stderr(std::process::Stdio::null())
.output()
.ok()
.filter(|output| output.status.success())?;
Some(
output
.stdout
.split(|byte| *byte == 0)
.filter(|name| !name.is_empty())
.map(|name| PathBuf::from(String::from_utf8_lossy(name).into_owned()))
.collect(),
)
}
fn walked_sources(project: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut pending = vec![project.to_path_buf()];
while let Some(dir) = pending.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
match entry.file_type() {
Ok(kind) if kind.is_dir() => {
if !NOT_SOURCES.iter().any(|skip| entry.file_name() == *skip) {
pending.push(path);
}
}
Ok(_) => {
if let Ok(relative) = path.strip_prefix(project) {
found.push(relative.to_path_buf());
}
}
Err(_) => {}
}
}
}
found
}
pub fn sha256(path: &Path) -> Result<String> {
use sha2::Digest;
use std::io::Read;
let mut hasher = sha2::Sha256::new();
let mut file = std::fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
let mut buffer = [0u8; 64 * 1024];
loop {
let read = file.read(&mut buffer).with_context(|| format!("cannot read {}", path.display()))?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plans_a_nested_tree() {
let root = tempfile::tempdir().expect("temp dir");
let deep = root.path().join("assets").join("img");
std::fs::create_dir_all(&deep).expect("create dirs");
std::fs::write(root.path().join("index.html"), "hello").expect("write");
std::fs::write(deep.join("logo.svg"), "12345678").expect("write");
let plan = plan_upload(root.path()).expect("plan");
assert_eq!(plan.files, 2);
assert_eq!(plan.bytes, 13);
assert_eq!(plan.dirs, vec!["assets", "assets/img"]);
let mut relative: Vec<_> = plan.entries.iter().map(|e| e.relative.as_str()).collect();
relative.sort_unstable();
assert_eq!(relative, vec!["assets/img/logo.svg", "index.html"]);
assert!(plan.newest.is_some(), "the build's moment is read off its files");
}
#[test]
fn only_trees_worth_packing_are_packed() {
assert!(should_archive(2_000, false), "a dist directory packs");
assert!(should_archive(ARCHIVE_THRESHOLD, false), "the threshold itself packs");
assert!(!should_archive(ARCHIVE_THRESHOLD - 1, false), "just below it does not");
assert!(!should_archive(1, false), "a single file never packs");
assert!(!should_archive(2_000, true), "--no-archive wins over any size");
}
#[test]
fn the_archive_holds_relative_paths_and_content() {
let root = tempfile::tempdir().expect("temp dir");
let deep = root.path().join("assets").join("img");
std::fs::create_dir_all(&deep).expect("create dirs");
std::fs::write(root.path().join("index.html"), "<!doctype html>").expect("write");
std::fs::write(deep.join("logo.svg"), "<svg/>").expect("write");
let plan = plan_upload(root.path()).expect("plan");
let archive = pack(&plan).expect("pack");
let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(&archive));
let mut tar = tar::Archive::new(decoder);
let mut found = std::collections::BTreeMap::new();
for entry in tar.entries().expect("entries") {
use std::io::Read;
let mut entry = entry.expect("entry");
let path = entry.path().expect("path").to_string_lossy().replace('\\', "/");
let mut content = String::new();
entry.read_to_string(&mut content).expect("read");
found.insert(path, content);
}
assert_eq!(found.get("index.html").map(String::as_str), Some("<!doctype html>"));
assert_eq!(found.get("assets/img/logo.svg").map(String::as_str), Some("<svg/>"));
assert_eq!(found.len(), 2, "only files travel; directories come from the paths: {found:?}");
assert!(
!found.keys().any(|p| p.starts_with('/') || p.starts_with("./")),
"paths must be relative: {found:?}"
);
}
#[test]
fn the_archive_is_smaller_than_the_tree() {
let root = tempfile::tempdir().expect("temp dir");
for index in 0..20 {
std::fs::write(root.path().join(format!("chunk-{index}.js")), "console.log('hello world');\n".repeat(200)).expect("write");
}
let plan = plan_upload(root.path()).expect("plan");
let archive = pack(&plan).expect("pack");
assert!(
(archive.len() as u64) < plan.bytes / 4,
"expected real compression, got {} from {}",
archive.len(),
plan.bytes
);
}
#[test]
fn an_artifact_packs_once() {
let root = tempfile::tempdir().expect("temp dir");
std::fs::write(root.path().join("index.html"), "hello").expect("write");
let artifact = Artifact::new("web", plan_upload(root.path()).unwrap());
let first = artifact.archive().unwrap().as_ptr();
let second = artifact.archive().unwrap().as_ptr();
assert_eq!(first, second, "the second target must not pack again");
}
#[test]
fn an_empty_tree_plans_nothing() {
let root = tempfile::tempdir().expect("temp dir");
let plan = plan_upload(root.path()).expect("plan");
assert_eq!(plan.files, 0);
assert!(plan.entries.is_empty());
assert_eq!(plan.newest, None);
}
fn touch(path: &Path, at: SystemTime) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "x").unwrap();
std::fs::File::options().write(true).open(path).unwrap().set_modified(at).unwrap();
}
#[test]
fn a_source_newer_than_the_build_is_named() {
let project = tempfile::tempdir().unwrap();
let built = SystemTime::now() - Duration::from_secs(3600);
touch(&project.path().join("dist/index.html"), built);
touch(&project.path().join("src/main.ts"), built - Duration::from_secs(60));
touch(&project.path().join(".env.development.local"), built + Duration::from_secs(600));
touch(&project.path().join("node_modules/x/index.js"), built + Duration::from_secs(600));
let dist = project.path().join("dist");
let skip = [Path::new(".env.development.local")];
assert_eq!(stale(project.path(), &dist, built, &skip), None, "nothing the build reads changed");
touch(&project.path().join("src/App.vue"), built + Duration::from_secs(720));
let found = stale(project.path(), &dist, built, &skip).expect("stale");
assert_eq!(found.source, "src/App.vue");
assert_eq!(found.after.as_secs(), 720);
}
#[test]
fn a_save_within_the_slack_is_not_stale() {
let project = tempfile::tempdir().unwrap();
let built = SystemTime::now() - Duration::from_secs(60);
touch(&project.path().join("src/a.ts"), built + Duration::from_secs(1));
assert_eq!(stale(project.path(), &project.path().join("dist"), built, &[]), None);
}
#[test]
fn sha256_matches_the_standard_vector() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("abc");
std::fs::write(&file, "abc").unwrap();
assert_eq!(sha256(&file).unwrap(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
}
}