use std::fs;
use std::num::NonZeroU8;
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::fs::symlink as symlink_dir;
#[cfg(windows)]
use std::os::windows::fs::symlink_dir;
use anyhow::{Context, Error, Result};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NumberedDir {
path: PathBuf,
base: String,
number: u16,
}
impl NumberedDir {
pub fn create(parent: impl AsRef<Path>, base: &str, count: NonZeroU8) -> Result<Self> {
if base.contains('/') || base.contains('\\') {
return Err(Error::msg("base must not contain path separators"));
}
fs::create_dir_all(&parent).context("Could not create parent")?;
let next_count = match current_entry_count(&parent, base) {
Some(current_count) => {
remove_obsolete_dirs(&parent, base, current_count, u8::from(count) - 1)?;
current_count.wrapping_add(1)
}
None => 0,
};
create_next_dir(&parent, base, next_count)
}
pub fn iterate(parent: impl AsRef<Path>, base: &str) -> Result<NumberedDirIter> {
NumberedDirIter::try_new(parent, base)
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn base(&self) -> &str {
&self.base
}
pub fn number(&self) -> u16 {
self.number
}
pub fn create_subdir(&self, rel_path: impl AsRef<Path>) -> Result<PathBuf> {
let rel_path = rel_path.as_ref();
if !rel_path.is_relative() {
return Err(Error::msg(format!(
"Not a relative path: {}",
rel_path.display()
)));
}
let file_name = rel_path.file_name().ok_or_else(|| {
Error::msg(format!(
"Subdir does not end in a filename: {}",
rel_path.display()
))
})?;
if let Some(parent) = rel_path.parent() {
let parent_path = self.path.join(parent);
fs::create_dir_all(&parent_path).with_context(|| {
format!("Failed to create subdir parent: {}", parent_path.display())
})?;
}
let mut full_path = self.path.join(rel_path);
for i in 0..u16::MAX {
match fs::create_dir(&full_path) {
Ok(_) => {
return Ok(full_path);
}
Err(_) => {
let mut new_file_name = file_name.to_os_string();
new_file_name.push(format!("-{}", i));
full_path.set_file_name(new_file_name);
}
}
}
Err(Error::msg(
"subdir conflict: all filename alternatives exhausted",
))
}
}
fn remove_obsolete_dirs(dir: impl AsRef<Path>, base: &str, current: u16, keep: u8) -> Result<()> {
let oldest_to_keep = current.wrapping_sub(keep as u16).wrapping_add(1);
let oldest_to_delete = current.wrapping_add(u16::MAX / 2);
assert!(oldest_to_keep != oldest_to_delete);
for numdir in NumberedDir::iterate(&dir, base)? {
if (oldest_to_keep > oldest_to_delete
&& (numdir.number < oldest_to_keep && numdir.number >= oldest_to_delete))
|| (oldest_to_keep < oldest_to_delete
&& (numdir.number < oldest_to_keep || numdir.number >= oldest_to_delete))
{
fs::remove_dir_all(numdir.path())
.with_context(|| format!("Failed to remove {}", numdir.path().display()))?;
}
}
Ok(())
}
fn create_next_dir(dir: impl AsRef<Path>, base: &str, mut next_count: u16) -> Result<NumberedDir> {
let mut last_err = None;
for _i in 0..16 {
let name = format!("{}-{}", base, next_count);
let path = dir.as_ref().join(name);
match fs::create_dir(&path) {
Ok(_) => {
let current = dir.as_ref().join(format!("{}-current", base));
if current.exists() {
fs::remove_file(¤t).with_context(|| {
format!("Failed to remove obsolete {}-current symlink", base)
})?;
}
symlink_dir(&path, ¤t).ok();
return Ok(NumberedDir {
path,
base: base.to_string(),
number: next_count,
});
}
Err(err) => {
next_count = next_count.wrapping_add(1);
last_err = Some(err);
}
}
}
Err(Error::new(last_err.expect("no last error")).context("Failed to create numbered dir"))
}
fn current_entry_count(dir: impl AsRef<Path>, base: &str) -> Option<u16> {
NumberedDirIter::try_new(dir, base)
.ok()?
.map(|entry| entry.number)
.max()
}
#[derive(Debug)]
pub struct NumberedDirIter {
prefix: String,
readdir: fs::ReadDir,
}
impl NumberedDirIter {
fn try_new(dir: impl AsRef<Path>, base: &str) -> Result<Self> {
Ok(Self {
prefix: format!("{}-", base),
readdir: dir
.as_ref()
.read_dir()
.with_context(|| format!("Failed read_dir() on {}", dir.as_ref().display()))?,
})
}
}
impl Iterator for NumberedDirIter {
type Item = NumberedDir;
fn next(&mut self) -> Option<Self::Item> {
loop {
let mut dirent = self.readdir.next()?;
while dirent.is_err() {
dirent = self.readdir.next()?;
}
let dirent = dirent.ok()?;
let os_name = dirent.file_name();
let count = os_name
.to_str()
.and_then(|name| name.strip_prefix(&self.prefix))
.and_then(|suffix| suffix.parse::<u16>().ok());
if let Some(count) = count {
return Some(NumberedDir {
path: dirent.path(),
base: self
.prefix
.strip_suffix('-')
.unwrap_or(&self.prefix)
.to_string(),
number: count,
});
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_numbered_creation() {
let parent = tempfile::tempdir().unwrap();
let dir_0 = NumberedDir::create(parent.path(), "base", NonZeroU8::new(3).unwrap()).unwrap();
assert_eq!(dir_0.path(), parent.path().join("base-0"));
assert!(dir_0.path().is_dir());
}
#[test]
fn test_numberd_creation_multiple() {
let parent = tempfile::tempdir().unwrap();
let dir_0 = NumberedDir::create(parent.path(), "base", NonZeroU8::new(3).unwrap()).unwrap();
assert_eq!(dir_0.path(), parent.path().join("base-0"));
assert!(dir_0.path().is_dir());
let dir_1 = NumberedDir::create(parent.path(), "base", NonZeroU8::new(3).unwrap()).unwrap();
assert_eq!(dir_1.path(), parent.path().join("base-1"));
assert!(dir_0.path().is_dir());
assert!(dir_1.path().is_dir());
let dir_2 = NumberedDir::create(parent.path(), "base", NonZeroU8::new(3).unwrap()).unwrap();
assert_eq!(dir_2.path(), parent.path().join("base-2"));
assert!(dir_0.path().is_dir());
assert!(dir_1.path().is_dir());
assert!(dir_2.path().is_dir());
let dir_3 = NumberedDir::create(parent.path(), "base", NonZeroU8::new(3).unwrap()).unwrap();
assert_eq!(dir_3.path(), parent.path().join("base-3"));
assert!(!dir_0.path().exists());
assert!(dir_1.path().is_dir());
assert!(dir_2.path().is_dir());
assert!(dir_3.path().is_dir());
let dir_4 = NumberedDir::create(parent.path(), "base", NonZeroU8::new(3).unwrap()).unwrap();
assert_eq!(dir_4.path(), parent.path().join("base-4"));
assert!(!dir_0.path().exists());
assert!(!dir_1.path().exists());
assert!(dir_2.path().is_dir());
assert!(dir_3.path().is_dir());
assert!(dir_4.path().is_dir());
}
#[test]
fn test_numbered_creation_current() {
let parent = tempfile::tempdir().unwrap();
let dir_0 = NumberedDir::create(parent.path(), "base", NonZeroU8::new(3).unwrap()).unwrap();
assert_eq!(dir_0.path(), parent.path().join("base-0"));
assert!(dir_0.path().is_dir());
let current = fs::read_link(parent.path().join("base-current")).unwrap();
assert_eq!(dir_0.path(), current);
let dir_1 = NumberedDir::create(parent.path(), "base", NonZeroU8::new(3).unwrap()).unwrap();
assert_eq!(dir_1.path(), parent.path().join("base-1"));
assert!(dir_0.path().is_dir());
assert!(dir_1.path().is_dir());
let current = fs::read_link(parent.path().join("base-current")).unwrap();
assert_eq!(dir_1.path(), current);
}
#[test]
fn test_numbered_subdir() {
let parent = tempfile::tempdir().unwrap();
let dir = NumberedDir::create(parent.path(), "base", NonZeroU8::new(3).unwrap()).unwrap();
let sub = dir.create_subdir(Path::new("sub")).unwrap();
assert_eq!(sub, dir.path().join("sub"));
assert!(sub.is_dir());
let sub_0 = dir.create_subdir(Path::new("sub")).unwrap();
assert_eq!(sub_0, dir.path().join("sub-0"));
assert!(sub_0.is_dir());
}
#[test]
fn test_numbered_subdir_nested() {
let parent = tempfile::tempdir().unwrap();
let dir = NumberedDir::create(parent.path(), "base", NonZeroU8::new(3).unwrap()).unwrap();
let sub = dir.create_subdir(Path::new("one/two")).unwrap();
assert_eq!(sub, dir.path().join("one/two"));
assert!(dir.path().join("one").is_dir());
assert!(dir.path().join("one").join("two").is_dir());
}
#[test]
fn test_iter() {
let parent = tempfile::tempdir().unwrap();
fs::write(parent.path().join("oops"), "not a numbered dir").unwrap();
let dir0 = NumberedDir::create(parent.path(), "base", NonZeroU8::new(3).unwrap()).unwrap();
let dir1 = NumberedDir::create(parent.path(), "base", NonZeroU8::new(3).unwrap()).unwrap();
let dirs = vec![dir0, dir1];
for numdir in NumberedDir::iterate(parent.path(), "base").unwrap() {
assert!(dirs.contains(&numdir));
}
}
}