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)
}
#[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 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_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()));
}
}