use std::path::{Component, Path, PathBuf};
use crate::error::Error;
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Targets {
pub named: Vec<String>,
pub unnamed: Vec<PathBuf>,
}
pub fn targets(shep_home: &Path) -> Result<Targets, Error> {
let root = shep_home.join("deploy");
let entries = match std::fs::read_dir(&root) {
Ok(entries) => entries,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return Ok(Targets::default());
}
Err(source) => return Err(Error::Io { path: root, source }),
};
let mut found = Targets::default();
for entry in entries {
let entry = entry.map_err(Error::at(&root))?;
if !entry.path().join("deploy.toml").is_file() {
continue;
}
match entry.file_name().to_str() {
Some(name) if is_sheep_name(name) => found.named.push(name.to_owned()),
_ => found.unnamed.push(entry.path()),
}
}
found.named.sort();
found.unnamed.sort();
Ok(found)
}
pub const REPOS: &str = "repos";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repo {
root: PathBuf,
}
impl Repo {
#[must_use]
pub fn for_remote(shep_home: &Path, remote: &str) -> Self {
Self::keyed(&shep_home.join("deploy").join(REPOS), remote)
}
fn keyed(repos: &Path, remote: &str) -> Self {
let normalized = normalized_remote(remote);
let tail: String = normalized
.rsplit('/')
.next()
.unwrap_or_default()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'_'
}
})
.take(40)
.collect();
let tail = tail.trim_start_matches('.');
let tail = if tail.is_empty() { "repo" } else { tail };
Self {
root: repos.join(format!("{tail}-{:016x}", fnv1a(&normalized))),
}
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn git(&self) -> PathBuf {
self.root.join("git")
}
}
fn normalized_remote(remote: &str) -> String {
let trimmed = remote.trim().trim_end_matches('/');
let trimmed = trimmed
.strip_suffix(".git")
.unwrap_or(trimmed)
.trim_end_matches('/');
let authority_end = trimmed.find("://").map_or(0, |scheme| {
let after = scheme + "://".len();
trimmed[after..]
.find('/')
.map_or(trimmed.len(), |at| after + at)
});
format!(
"{}{}",
trimmed[..authority_end].to_ascii_lowercase(),
&trimmed[authority_end..]
)
}
fn fnv1a(text: &str) -> u64 {
text.bytes().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
(hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tree {
root: PathBuf,
sheep: String,
}
#[must_use]
pub fn is_sheep_name(sheep: &str) -> bool {
let mut components = Path::new(sheep).components();
let one_ordinary_component =
matches!(components.next(), Some(Component::Normal(only)) if only == sheep);
one_ordinary_component
&& components.next().is_none()
&& !sheep.chars().any(crate::shared::forges_a_line)
}
impl Tree {
#[must_use]
pub fn for_sheep(shep_home: &Path, sheep: &str) -> Self {
debug_assert!(
is_sheep_name(sheep),
"a sheep name is one path component, got {sheep:?}"
);
Self {
root: shep_home.join("deploy").join(sheep),
sheep: sheep.to_owned(),
}
}
#[must_use]
pub fn sheep(&self) -> &str {
&self.sheep
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn git(&self) -> PathBuf {
self.root.join("git")
}
#[must_use]
pub fn owns_clone(&self) -> bool {
std::fs::symlink_metadata(self.git()).is_ok_and(|meta| meta.file_type().is_dir())
}
#[must_use]
pub fn linked_clone(&self) -> Option<Repo> {
let target = std::fs::read_link(self.git()).ok()?;
let root = target.parent()?;
let repos = self.root.parent()?.join(REPOS);
(target.file_name()? == "git"
&& root.parent()? == repos
&& matches!(
Path::new(root.file_name()?).components().next(),
Some(Component::Normal(_))
))
.then(|| Repo {
root: root.to_owned(),
})
}
pub fn link_clone(&self, repo: &Repo) -> Result<(), Error> {
let (link, target) = (self.git(), repo.git());
match std::fs::read_link(&link) {
Ok(current) if current == target => return Ok(()),
Ok(_) => std::fs::remove_file(&link).map_err(Error::at(&link))?,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
Err(source) => return Err(Error::Io { path: link, source }),
}
std::os::unix::fs::symlink(target, &link).map_err(Error::at(link))
}
#[must_use]
pub fn clone_serves(&self, remote: &str) -> bool {
let Some(linked) = self.linked_clone() else {
return true;
};
self.root
.parent()
.is_some_and(|deploy| linked == Repo::keyed(&deploy.join(REPOS), remote))
}
#[must_use]
pub fn releases(&self) -> PathBuf {
self.root.join("releases")
}
#[must_use]
pub fn release(&self, sha: &str) -> PathBuf {
self.releases().join(sha)
}
#[must_use]
pub fn current(&self) -> PathBuf {
self.root.join("current")
}
#[must_use]
pub fn completion(&self, sha: &str) -> PathBuf {
self.completions().join(sha)
}
#[must_use]
pub fn completions(&self) -> PathBuf {
self.root.join("complete")
}
#[must_use]
pub fn lock_file(&self) -> PathBuf {
self.root.join("deploy.lock")
}
#[must_use]
pub fn state_file(&self) -> PathBuf {
self.root.join("deploy.toml")
}
#[must_use]
pub fn record_lock(&self) -> PathBuf {
self.root.join("deploy.toml.lock")
}
#[must_use]
pub fn cache(&self) -> PathBuf {
self.root.join("cache")
}
#[must_use]
pub fn cache_target(&self) -> PathBuf {
self.cache().join("target")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_path_matches_the_documented_layout() {
let tree = Tree::for_sheep(Path::new("/srv/shep"), "bpm");
assert_eq!(tree.git(), Path::new("/srv/shep/deploy/bpm/git"));
assert_eq!(tree.releases(), Path::new("/srv/shep/deploy/bpm/releases"));
assert_eq!(
tree.release("a1b2c3d"),
Path::new("/srv/shep/deploy/bpm/releases/a1b2c3d")
);
assert_eq!(tree.current(), Path::new("/srv/shep/deploy/bpm/current"));
assert_eq!(
tree.completion("a1b2c3d"),
Path::new("/srv/shep/deploy/bpm/complete/a1b2c3d")
);
assert_eq!(
tree.lock_file(),
Path::new("/srv/shep/deploy/bpm/deploy.lock")
);
assert_eq!(
tree.state_file(),
Path::new("/srv/shep/deploy/bpm/deploy.toml")
);
}
#[test]
fn is_sheep_name_accepts_one_component_and_rejects_everything_else() {
for accepted in ["bpm", "reactmap-staging", "web.2"] {
assert!(is_sheep_name(accepted), "should accept {accepted:?}");
}
for rejected in ["", ".", "..", "/tmp/x", "../x", "a/b", "a/"] {
assert!(!is_sheep_name(rejected), "should reject {rejected:?}");
}
}
#[test]
fn a_non_utf8_target_name_is_an_error_naming_the_entry() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let home = tempfile::tempdir().expect("tempdir");
let deploy = home.path().join("deploy");
let bad_dir = deploy.join(OsStr::from_bytes(&[0xff, 0xfe]));
if let Err(err) = std::fs::create_dir_all(&bad_dir) {
eprintln!("skipped: this filesystem refuses the name ({err})");
return;
}
std::fs::write(bad_dir.join("deploy.toml"), "").expect("write deploy.toml");
let found = targets(home.path()).expect("the listing itself succeeds");
assert_eq!(
found.unnamed,
vec![bad_dir],
"the entry is reported, not skipped"
);
assert!(found.named.is_empty());
}
#[test]
fn an_unnameable_target_does_not_hide_the_named_ones() {
let home = tempfile::tempdir().expect("tempdir");
let deploy = home.path().join("deploy");
for name in ["web", "bad\nname"] {
let dir = deploy.join(name);
std::fs::create_dir_all(&dir).expect("dir");
std::fs::write(dir.join("deploy.toml"), "").expect("record");
}
let found = targets(home.path()).expect("lists");
assert_eq!(found.named, vec!["web".to_owned()]);
assert_eq!(found.unnamed, vec![deploy.join("bad\nname")]);
}
#[test]
fn a_repo_is_keyed_by_its_normalized_remote() {
let home = Path::new("/srv/shep");
let key = |remote: &str| Repo::for_remote(home, remote).root().to_owned();
let one = key("https://github.com/shep-pm/shep-deploy");
for same in [
"https://github.com/shep-pm/shep-deploy.git",
"https://github.com/shep-pm/shep-deploy/",
"https://github.com/shep-pm/shep-deploy.git/",
" https://github.com/shep-pm/shep-deploy\n",
"HTTPS://GitHub.com/shep-pm/shep-deploy",
] {
assert_eq!(key(same), one, "{same:?} is the same remote");
}
for other in [
"https://github.com/shep-pm/shep",
"https://github.com/Shep-pm/shep-deploy",
"git@github.com:shep-pm/shep-deploy",
"/srv/git/shep-deploy",
] {
assert_ne!(key(other), one, "{other:?} is a different remote");
}
}
#[test]
fn the_shared_clone_lives_under_repos_in_one_component() {
let repo = Repo::for_remote(Path::new("/srv/shep"), "../../etc/passwd");
let rel = repo
.git()
.strip_prefix("/srv/shep/deploy/repos")
.expect("under repos")
.to_owned();
let parts: Vec<_> = rel.components().collect();
assert_eq!(parts.len(), 2, "<key>/git, got {rel:?}");
assert_eq!(parts[1].as_os_str(), "git");
assert!(
matches!(parts[0], Component::Normal(_)),
"key is an ordinary component: {rel:?}"
);
}
#[test]
fn only_a_link_into_this_deploy_dirs_repos_is_a_shared_clone() {
let home = tempfile::tempdir().expect("tempdir");
let tree = Tree::for_sheep(home.path(), "web");
std::fs::create_dir_all(tree.root()).expect("tree");
let elsewhere = tempfile::tempdir().expect("tempdir");
let outside = elsewhere.path().join("repos").join("x").join("git");
std::os::unix::fs::symlink(&outside, tree.git()).expect("link");
assert_eq!(tree.linked_clone(), None, "outside");
std::fs::remove_file(tree.git()).expect("unlink");
let repo = Repo::for_remote(home.path(), "https://example.com/x");
tree.link_clone(&repo).expect("link");
assert_eq!(tree.linked_clone(), Some(repo), "inside");
}
#[test]
fn a_tree_knows_its_own_sheep() {
assert_eq!(
Tree::for_sheep(Path::new("/srv/shep"), "bpm").sheep(),
"bpm"
);
}
#[test]
fn different_sheep_get_different_trees() {
let a = Tree::for_sheep(Path::new("/srv/shep"), "bpm");
let b = Tree::for_sheep(Path::new("/srv/shep"), "ctm");
assert_ne!(a.state_file(), b.state_file());
}
#[test]
fn the_build_cache_lives_in_the_tree() {
let tree = Tree::for_sheep(Path::new("/srv/shep"), "koji");
assert_eq!(tree.cache(), Path::new("/srv/shep/deploy/koji/cache"));
assert_eq!(
tree.cache_target(),
Path::new("/srv/shep/deploy/koji/cache/target")
);
}
#[test]
fn the_cache_is_not_inside_releases() {
let tree = Tree::for_sheep(Path::new("/srv/shep"), "koji");
assert!(!tree.cache().starts_with(tree.releases()));
}
}