use std::ffi::OsStr;
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::time::SystemTime;
use crate::shared::{FromCheckout, printable};
const BUILD_DIRECTORIES: [&str; 4] = ["node_modules", ".output", "dist", ".next"];
pub(crate) fn warnings(sheep: &str, shared: &FromCheckout, artifacts: &[PathBuf]) -> Vec<String> {
shared
.iter()
.filter_map(|path| {
let reason = reason(path, artifacts)?;
Some(format!(
"shep-deploy: {sheep}: warning: `{}` is linked from the checkout into every \
release, and {reason}. Add it to `.shepignore` in the checkout to stop linking it.",
printable(path.display()),
))
})
.collect()
}
fn reason(path: &Path, artifacts: &[PathBuf]) -> Option<String> {
let names = names(path);
if let Some(name) = names
.iter()
.find_map(|name| BUILD_DIRECTORIES.iter().find(|build| *name == **build))
{
return Some(format!(
"it has a `{name}` component, which holds build output"
));
}
artifacts.iter().find_map(|artifact| {
let declared = self::names(artifact);
(!declared.is_empty() && (names.starts_with(&declared) || declared.starts_with(&names)))
.then(|| {
format!(
"it overlaps `build.artifacts` entry `{}`, which the build writes",
printable(artifact.display())
)
})
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Record {
modified: SystemTime,
len: Option<u64>,
}
fn read(release: &Path, path: &Path) -> Option<Record> {
let link = release.join(path);
if !link.symlink_metadata().ok()?.is_symlink() {
return None;
}
let meta = fs::metadata(&link).ok()?;
Some(Record {
modified: meta.modified().ok()?,
len: meta.is_file().then_some(meta.len()),
})
}
#[derive(Debug)]
pub(crate) struct Snapshot {
sheep: String,
release: PathBuf,
records: Vec<(PathBuf, Record)>,
}
impl Snapshot {
#[must_use]
pub(crate) fn before(sheep: &str, release: &Path, shared: &FromCheckout) -> Self {
let records = shared
.iter()
.filter_map(|path| Some((path.clone(), read(release, path)?)))
.collect();
Self {
sheep: sheep.to_owned(),
release: release.to_owned(),
records,
}
}
#[must_use]
pub(crate) fn after(self) -> Vec<String> {
let Self {
sheep,
release,
records,
} = self;
records
.into_iter()
.filter(|(path, before)| read(&release, path).is_some_and(|now| now != *before))
.map(|(path, _)| {
format!(
"shep-deploy: {sheep}: warning: `{}` changed while the build ran. If the \
build wrote through the link into the checkout, where it is linked from \
into every release, add it to `.shepignore` in the checkout to stop \
linking it. If the running app writes it, this is expected and the \
path should stay linked.",
printable(path.display()),
)
})
.collect()
}
}
fn names(path: &Path) -> Vec<&OsStr> {
path.components()
.filter_map(|component| match component {
Component::Normal(name) => Some(name),
_ => None,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
use std::time::Duration;
fn linked_release(files: &[&str]) -> (tempfile::TempDir, PathBuf, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let (checkout, release) = (dir.path().join("checkout"), dir.path().join("release"));
fs::create_dir_all(&checkout).expect("checkout");
fs::create_dir_all(&release).expect("release");
for name in files {
fs::write(checkout.join(name), "built by the last release").expect("write");
symlink(checkout.join(name), release.join(name)).expect("link");
age(&checkout.join(name));
}
(dir, checkout, release)
}
fn age(path: &Path) {
fs::File::open(path)
.expect("open")
.set_modified(SystemTime::now() - Duration::from_secs(3600))
.expect("set_modified");
}
fn linked(paths: &[&str]) -> FromCheckout {
FromCheckout::of(paths.iter().map(PathBuf::from).collect())
}
#[test]
fn a_linked_build_directory_is_named() {
for name in ["node_modules", ".output", "dist", ".next"] {
for path in [name.to_string(), format!("packages/app/{name}")] {
let found = warnings("web", &linked(&[&path]), &[]);
assert_eq!(found.len(), 1, "{path}: {found:?}");
assert!(found[0].starts_with("shep-deploy: web:"), "{found:?}");
assert!(found[0].contains(&path), "{found:?}");
assert!(found[0].contains(".shepignore"), "{found:?}");
}
}
}
#[test]
fn a_linked_path_that_overlaps_an_artifact_is_named() {
let artifacts = [PathBuf::from("out/bin/koji")];
for path in ["out/bin/koji", "out", "out/bin/koji/debug"] {
let found = warnings("web", &linked(&[path]), &artifacts);
assert_eq!(found.len(), 1, "{path}: {found:?}");
assert!(found[0].starts_with("shep-deploy: web:"), "{found:?}");
assert!(found[0].contains("out/bin/koji"), "{found:?}");
assert!(found[0].contains(".shepignore"), "{found:?}");
}
}
#[test]
fn an_ordinary_shared_file_is_not_named() {
let artifacts = [PathBuf::from("out/bin/koji")];
let found = warnings(
"web",
&linked(&[".env", "config/local.json", "Flockfile.override.toml"]),
&artifacts,
);
assert!(found.is_empty(), "{found:?}");
}
#[test]
fn a_name_that_only_contains_a_build_directory_name_is_not_named() {
let found = warnings("web", &linked(&["distinct", "my_dist", "dist.log"]), &[]);
assert!(found.is_empty(), "{found:?}");
}
#[test]
fn an_artifact_that_names_no_component_overlaps_nothing() {
let artifacts = [PathBuf::from(".")];
let found = warnings("web", &linked(&[".env"]), &artifacts);
assert!(found.is_empty(), "{found:?}");
}
#[test]
fn a_warning_cannot_forge_a_line() {
let path = "dist/x\nshep-deploy: web: forged";
let found = warnings("web", &linked(&[path]), &[]);
assert_eq!(found.len(), 1, "{found:?}");
assert!(!found[0].contains('\n'), "{found:?}");
}
#[test]
fn a_build_that_writes_through_a_linked_file_is_named() {
let (_dir, checkout, release) = linked_release(&["koji"]);
let files = linked(&["koji"]);
let snapshot = Snapshot::before("web", &release, &files);
fs::write(release.join("koji"), "rebuilt").expect("write through");
let found = snapshot.after();
assert_eq!(found.len(), 1, "{found:?}");
assert!(found[0].starts_with("shep-deploy: web:"), "{found:?}");
assert!(found[0].contains("`koji`"), "{found:?}");
assert!(
found[0].contains("changed while the build ran"),
"{found:?}"
);
assert!(found[0].contains("the running app"), "{found:?}");
assert!(found[0].contains(".shepignore"), "{found:?}");
assert_eq!(
fs::read_to_string(checkout.join("koji")).expect("read"),
"rebuilt"
);
}
#[test]
fn a_build_that_leaves_a_linked_file_alone_is_silent() {
let (_dir, _checkout, release) = linked_release(&[".env", "config.json"]);
let files = linked(&[".env", "config.json"]);
let snapshot = Snapshot::before("web", &release, &files);
let found = snapshot.after();
assert!(found.is_empty(), "{found:?}");
}
#[test]
fn a_build_that_replaces_the_link_with_its_own_file_is_silent() {
let (_dir, checkout, release) = linked_release(&["koji"]);
let files = linked(&["koji"]);
let snapshot = Snapshot::before("web", &release, &files);
fs::remove_file(release.join("koji")).expect("remove link");
fs::write(release.join("koji"), "the build's own").expect("own file");
let found = snapshot.after();
assert!(found.is_empty(), "{found:?}");
assert_eq!(
fs::read_to_string(checkout.join("koji")).expect("read"),
"built by the last release"
);
}
#[test]
fn a_linked_directory_that_gains_an_entry_is_named() {
let (_dir, checkout, release) = linked_release(&[]);
fs::create_dir(checkout.join("out")).expect("dir");
symlink(checkout.join("out"), release.join("out")).expect("link");
age(&checkout.join("out"));
let files = linked(&["out"]);
let snapshot = Snapshot::before("web", &release, &files);
fs::write(release.join("out/koji"), "built").expect("new entry");
let found = snapshot.after();
assert_eq!(found.len(), 1, "{found:?}");
assert!(found[0].contains("`out`"), "{found:?}");
}
#[test]
fn a_linked_path_that_cannot_be_read_is_skipped() {
let (_dir, checkout, release) = linked_release(&["live"]);
symlink(checkout.join("gone"), release.join("dangling")).expect("dangling link");
let files = linked(&["live", "dangling", "missing"]);
let snapshot = Snapshot::before("web", &release, &files);
fs::write(release.join("live"), "rebuilt").expect("write through");
fs::write(checkout.join("gone"), "now present").expect("appears");
let found = snapshot.after();
assert_eq!(found.len(), 1, "{found:?}");
assert!(found[0].contains("`live`"), "{found:?}");
}
}