use std::fmt::Display;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use clap::ValueEnum;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Eq, PartialEq, Clone, ValueEnum)]
pub enum NexterType {
First,
Last,
Previous,
Next,
Random,
Keep,
}
#[derive(Debug)]
pub enum Error {
Io(std::io::Error),
NotFound(PathBuf),
NoParent(PathBuf),
NotDir(PathBuf),
NotFile(PathBuf),
Fatal(String),
Array(Vec<Error>),
}
impl Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Io(e) => write!(f, "I/O error: {e}"),
Error::NotDir(path) => write!(f, "{}: Not a directory", path.display()),
Error::NoParent(path) => write!(f, "{}: No parent directory", path.display()),
Error::Array(array) => array
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
.fmt(f),
Error::NotFile(path) => write!(f, "{}: Not a file", path.display()),
Error::NotFound(path) => write!(f, "{}: Not found", path.display()),
Error::Fatal(message) => write!(f, "Fatal error: {message}"),
}
}
}
#[derive(Debug, Clone)]
pub struct Dirs {
entries: Vec<PathBuf>,
parent: PathBuf,
current: usize,
}
#[derive(Debug, Clone)]
pub struct Dir<'a> {
dirs: &'a Dirs,
index: usize,
last_item: bool,
}
impl Dir<'_> {
#[must_use]
pub fn new(dirs: &Dirs, index: usize) -> Dir<'_> {
log::trace!("Dir::new(index={index})");
Dir {
dirs,
index,
last_item: false,
}
}
#[must_use]
pub fn new_of_last_item(dirs: &Dirs, index: usize) -> Dir<'_> {
log::trace!("Dir::new_of_last_item(index={index})");
Dir {
dirs,
index,
last_item: true,
}
}
#[must_use]
pub fn path(&self) -> &Path {
&self.dirs.entries[self.index]
}
#[must_use]
pub fn index(&self) -> usize {
self.index
}
#[must_use]
pub fn is_last_item(&self) -> bool {
self.last_item
}
}
impl Dirs {
pub fn new<P: AsRef<Path>>(current_dir: P) -> Result<Self> {
let current_dir = current_dir.as_ref();
log::debug!("Dirs::new(current_dir={})", current_dir.display());
if current_dir == Path::new(".") {
match std::env::current_dir() {
Ok(dir) => build_dirs(dir.clone().parent(), dir),
Err(e) => {
log::error!("Dirs::new: I/O error: {e}");
Err(Error::Io(e))
}
}
} else if current_dir.exists() {
if current_dir.is_dir() {
let current = std::fs::canonicalize(current_dir).map_err(Error::Io)?;
build_dirs(current.clone().parent(), current)
} else {
log::error!("Dirs::new: Not a directory: {}", current_dir.display());
Err(Error::NotDir(current_dir.to_path_buf()))
}
} else {
log::error!("Dirs::new: Not found: {}", current_dir.display());
Err(Error::NotFound(current_dir.to_path_buf()))
}
}
pub fn new_from_file<S: AsRef<str>>(file: S) -> Result<Self> {
log::debug!("Dirs::new_from_file(file={})", file.as_ref());
let file = file.as_ref();
if file == "-" {
log::info!("Reading directories from stdin");
return Ok(build_from_reader(Box::new(std::io::stdin().lock())));
}
let path = PathBuf::from(file);
if !path.exists() {
log::error!("Dirs::new_from_file: Not found: {}", path.display());
Err(Error::NotFound(path))
} else if path.is_dir() {
log::error!("Dirs::new_from_file: Not a file: {}", path.display());
Err(Error::NotFile(path))
} else {
build_from_list(&path)
}
}
#[must_use]
pub fn parent(&self) -> &Path {
self.parent.as_path()
}
#[must_use]
pub fn current(&self) -> Dir<'_> {
Dir::new(self, self.current)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn len(&self) -> i32 {
i32::try_from(self.entries.len()).unwrap()
}
#[must_use]
pub fn next(&self, nexter: &dyn Nexter) -> Option<Dir<'_>> {
nexter.next_with(self, 1)
}
#[must_use]
pub fn next_with(&self, nexter: &dyn Nexter, step: usize) -> Option<Dir<'_>> {
nexter.next_with(self, i32::try_from(step).unwrap())
}
pub fn directories(&self) -> impl Iterator<Item = &PathBuf> {
self.entries.iter()
}
}
fn build_dirs(parent: Option<&Path>, current: PathBuf) -> Result<Dirs> {
log::trace!("build_dirs(parent={parent:?}, current={})", current.display());
let Some(parent) = parent else {
log::error!("build_dirs: No parent for current={}", current.display());
return Err(Error::NoParent(current));
};
let mut errs = vec![];
let dirs = collect_dirs(parent, &mut errs);
if errs.is_empty() {
let current_index = find_current(&dirs, ¤t);
let index = if current_index == -1 {
log::warn!(
"build_dirs: current directory not found in siblings: {}",
current.display()
);
0
} else {
usize::try_from(current_index).unwrap()
};
log::info!("build_dirs: siblings={}, current_index={index}", dirs.len());
Ok(Dirs {
entries: dirs,
parent: parent.to_path_buf(),
current: index,
})
} else {
Err(Error::Array(errs))
}
}
fn collect_dirs(parent: &Path, errs: &mut Vec<Error>) -> Vec<PathBuf> {
log::trace!("collect_dirs(parent={})", parent.display());
let mut dirs = vec![];
if let Ok(entries) = parent.read_dir() {
for entry in entries {
match entry {
Ok(entry) => {
let path = entry.path();
if path.is_dir() {
dirs.push(path);
}
}
Err(e) => {
log::error!("collect_dirs: I/O error: {e}");
errs.push(Error::Io(e));
}
}
}
}
if log::log_enabled!(log::Level::Warn) && dirs.is_empty() {
log::warn!("collect_dirs: no directories under {}", parent.display());
}
dirs.sort();
dirs
}
fn find_current(dirs: &[PathBuf], current: &PathBuf) -> i32 {
let idx = dirs
.iter()
.position(|dir| dir == current)
.map_or(-1, |i| i32::try_from(i).unwrap());
log::trace!("find_current: index={} for {}", idx, current.display());
idx
}
fn build_from_reader(reader: Box<dyn BufRead>) -> Dirs {
let lines = reader
.lines()
.filter_map(|line| line.map(|n| n.trim().to_string()).ok())
.collect::<Vec<String>>();
let base = if let Some(base) = lines.iter().find(|l| l.starts_with("parent:")) {
base.chars().skip(7).collect::<String>().trim().to_string()
} else {
".".to_string()
};
let dirs = lines
.iter()
.filter(|l| !l.starts_with("parent:"))
.map(PathBuf::from)
.collect::<Vec<PathBuf>>();
log::debug!("build_from_reader: base='{}', entries={}", base, dirs.len());
let current = find_current_dir_index(&dirs);
if current == 0 {
log::warn!("build_from_reader: current directory not found in siblings");
}
Dirs {
entries: dirs,
parent: PathBuf::from(base),
current,
}
}
fn find_current_dir_index(dirs: &[PathBuf]) -> usize {
log::trace!("find_current_dir_index(dirs.len={})", dirs.len());
if let Ok(pwd) = std::env::current_dir() {
let cwd = PathBuf::from(".");
if let Some(pos) = dirs
.iter()
.position(|dir| dir == &cwd || pwd.ends_with(dir))
{
return pos;
}
}
0
}
fn build_from_list(filename: &Path) -> Result<Dirs> {
if let Ok(f) = std::fs::File::open(filename) {
let reader = BufReader::new(f);
Ok(build_from_reader(Box::new(reader)))
} else {
log::error!("build_from_list: I/O error: {}", filename.display());
Err(Error::Io(std::io::Error::last_os_error()))
}
}
pub trait Nexter {
fn next_with<'a>(&self, dirs: &'a Dirs, step: i32) -> Option<Dir<'a>>;
fn next<'a>(&self, dirs: &'a Dirs) -> Option<Dir<'a>> {
self.next_with(dirs, 1)
}
}
pub struct NexterFactory {}
impl NexterFactory {
#[must_use]
pub fn build(nexter_type: &NexterType) -> Box<dyn Nexter> {
log::trace!("NexterFactory::build(nexter_type={nexter_type:?})");
match nexter_type {
NexterType::First => Box::new(First {}),
NexterType::Last => Box::new(Last {}),
NexterType::Previous => Box::new(Previous {}),
NexterType::Next => Box::new(Next {}),
NexterType::Random => Box::new(Random {}),
NexterType::Keep => Box::new(Keep {}),
}
}
}
struct First {}
struct Last {}
struct Previous {}
struct Next {}
struct Random {}
struct Keep {}
impl Nexter for First {
fn next_with<'a>(&self, dirs: &'a Dirs, _step: i32) -> Option<Dir<'a>> {
Some(Dir::new_of_last_item(dirs, 0))
}
}
impl Nexter for Last {
fn next_with<'a>(&self, dirs: &'a Dirs, _step: i32) -> Option<Dir<'a>> {
let next = dirs.len() - 1;
Some(Dir::new_of_last_item(dirs, usize::try_from(next).unwrap()))
}
}
impl Nexter for Previous {
fn next_with<'a>(&self, dirs: &'a Dirs, step: i32) -> Option<Dir<'a>> {
next_impl(dirs, -step)
}
}
impl Nexter for Next {
fn next_with<'a>(&self, dirs: &'a Dirs, step: i32) -> Option<Dir<'a>> {
next_impl(dirs, step)
}
}
impl Nexter for Random {
fn next_with<'a>(&self, dirs: &'a Dirs, _step: i32) -> Option<Dir<'a>> {
use rand::Rng;
let mut rng = rand::rng();
let next = rng.random_range(0..dirs.len());
log::trace!("Random::next_with -> index {next}");
Some(Dir::new(dirs, usize::try_from(next).unwrap()))
}
}
impl Nexter for Keep {
fn next_with<'a>(&self, dirs: &'a Dirs, _step: i32) -> Option<Dir<'a>> {
Some(dirs.current())
}
}
fn next_impl(dirs: &Dirs, step: i32) -> Option<Dir<'_>> {
let next = i32::try_from(dirs.current).unwrap() + step;
let length = dirs.len();
log::trace!(
"next_impl(step={step}, current={}, next={next})",
dirs.current
);
if next < 0 || next >= dirs.len() {
log::warn!(
"next_impl: out of range (next={next}, len={})",
dirs.len()
);
None
} else if next == 0 {
Some(Dir::new_of_last_item(dirs, 0))
} else if next == length - 1 {
Some(Dir::new_of_last_item(dirs, usize::try_from(length - 1).unwrap()))
} else {
Some(Dir::new(dirs, usize::try_from(next).unwrap()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dirs_new() {
let dirs = Dirs::new(PathBuf::from("../testdata/basic/d"));
assert!(dirs.is_ok());
let dirs = dirs.unwrap();
assert_eq!(dirs.len(), 26);
assert_eq!(dirs.current, 3);
}
#[test]
fn test_worried_dirs() {
let dirs = Dirs::new(PathBuf::from("../testdata/worried/dir with spaces"));
assert!(dirs.is_ok());
let dirs = dirs.unwrap();
assert_eq!(dirs.len(), 2);
assert_eq!(dirs.current, 0);
}
#[test]
fn test_dir_dot() {
let dirs = Dirs::new(PathBuf::from(".."));
assert!(dirs.is_ok());
let dirs = dirs.unwrap();
assert_eq!(
dirs.current().path().file_name().map(|s| s.to_str()),
Some("sibling".into())
);
}
#[test]
fn test_dirs() {
let dirs = Dirs::new(PathBuf::from("../testdata/basic/d")).unwrap();
let abspath = Path::new("../testdata/basic").canonicalize().unwrap();
assert_eq!(dirs.parent(), &abspath);
assert_eq!(dirs.current().index(), 3);
assert!(!dirs.is_empty());
assert_eq!(dirs.len(), 26);
}
#[test]
fn test_dir_from_file() {
let dirs = Dirs::new_from_file("../testdata/basic/dirlist.txt");
assert!(dirs.is_ok());
let dirs = dirs.unwrap();
assert_eq!(dirs.len(), 4);
assert_eq!(dirs.current, 1);
assert_eq!(dirs.parent, PathBuf::from("testdata/basic"));
}
#[test]
fn test_nexter_first() {
let dirs = Dirs::new("../testdata/basic/c").unwrap();
let nexter = NexterFactory::build(&NexterType::First);
match nexter.next(&dirs) {
Some(p) => assert!(p.path().ends_with("testdata/basic/a")),
None => panic!("unexpected None"),
}
}
#[test]
fn test_nexter_keep() {
let dirs = Dirs::new("../testdata/basic/c").unwrap();
let nexter = NexterFactory::build(&NexterType::Keep);
match nexter.next(&dirs) {
Some(p) => assert!(p.path().ends_with("testdata/basic/c")),
None => panic!("unexpected None"),
}
}
#[test]
fn test_nexter_last() {
let dirs = Dirs::new("../testdata/basic/k").unwrap();
let nexter = NexterFactory::build(&NexterType::Last);
match nexter.next(&dirs) {
Some(p) => assert!(p.path().ends_with("testdata/basic/z")),
None => panic!("unexpected None"),
}
}
#[test]
fn test_nexter_next() {
let dirs = Dirs::new("../testdata/basic/c").unwrap();
let nexter = NexterFactory::build(&NexterType::Next);
match nexter.next(&dirs) {
Some(p) => assert!(p.path().ends_with("testdata/basic/d")),
None => panic!("unexpected None"),
}
match nexter.next_with(&dirs, 2) {
Some(p) => assert!(p.path().ends_with("testdata/basic/e"), "{:?}", p.path()),
None => panic!("unexpected None"),
}
match nexter.next_with(&dirs, 23) {
Some(p) => assert!(p.path().ends_with("testdata/basic/z"), "{:?}", p.path()),
None => panic!("unexpected None"),
}
match nexter.next_with(&dirs, 24) {
None => {}
Some(p) => panic!("unexpected {:?}", p.path()),
}
}
#[test]
fn test_nexter_prev() {
let dirs = Dirs::new("../testdata/basic/k").unwrap();
let nexter = NexterFactory::build(&NexterType::Previous);
match nexter.next(&dirs) {
Some(p) => assert!(p.path().ends_with("testdata/basic/j")),
None => panic!("unexpected None"),
}
match nexter.next(&dirs) {
Some(p) => assert!(p.path().ends_with("testdata/basic/j")),
None => panic!("unexpected None"),
}
match nexter.next_with(&dirs, 4) {
Some(p) => assert!(p.path().ends_with("testdata/basic/g")),
None => panic!("unexpected None"),
}
match nexter.next_with(&dirs, 10) {
Some(p) => assert!(p.path().ends_with("testdata/basic/a")),
None => panic!("unexpected None"),
}
if let Some(p) = nexter.next_with(&dirs, 11) {
panic!("unexpected {:?}", p.path())
}
}
}