diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/cli.rs | 18 | ||||
-rw-r--r-- | src/config.rs | 20 | ||||
-rw-r--r-- | src/error.rs | 3 | ||||
-rw-r--r-- | src/lib.rs | 8 | ||||
-rw-r--r-- | src/main.rs | 9 | ||||
-rw-r--r-- | src/project.rs | 12 | ||||
-rw-r--r-- | src/project/git.rs | 90 | ||||
-rw-r--r-- | src/project/path.rs | 74 | ||||
-rw-r--r-- | src/projects.rs | 117 | ||||
-rw-r--r-- | src/projects/entry.rs | 76 | ||||
-rw-r--r-- | src/search.rs | 129 | ||||
-rw-r--r-- | src/search/entry.rs | 57 | ||||
-rw-r--r-- | src/search/entry/config.rs | 90 | ||||
-rw-r--r-- | src/sorter.rs | 62 |
14 files changed, 489 insertions, 276 deletions
@@ -1,4 +1,4 @@ -use crate::{Config, SearchPath}; +use crate::{search, Config}; use clap::{Args, Parser}; use figment::{value, Metadata, Profile, Provider}; use serde::{Deserialize, Serialize}; @@ -34,8 +34,16 @@ pub struct Projects { /// /// Traverse into hidden directories while searching. A directory is considered hidden /// if its name starts with a `.` sign (dot). If `--max-depth` is 0, this has no effect. - #[arg(long, default_value_t)] + #[arg(long)] hidden: bool, + + /// Match git repositories + #[arg(long, short)] + git: bool, + + /// Match directories containing item named <PATTERN> + #[arg(long, short)] + pattern: Option<String>, } impl Provider for Projects { @@ -54,10 +62,12 @@ impl From<Projects> for Config { .paths .iter() .cloned() - .map(|p| SearchPath { - path: p, + .map(|path_buf| search::entry::Config { + path_buf, hidden: value.hidden, max_depth: value.max_depth, + git: value.git, + pattern: value.pattern.to_owned(), }) .collect(); diff --git a/src/config.rs b/src/config.rs index 677b202..126b939 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,10 +1,11 @@ -use super::SearchPath; use figment::{providers::Serialized, value, Figment, Metadata, Profile, Provider}; use serde::{Deserialize, Serialize}; +use crate::search; + #[derive(Debug, PartialEq, Eq, Clone, Default, Serialize, Deserialize)] pub struct Config { - pub(crate) paths: Vec<SearchPath>, + pub(crate) paths: Vec<search::entry::Config>, } impl Config { @@ -57,20 +58,23 @@ mod tests { config, Config { paths: Vec::from([ - SearchPath { - path: "/path/to/projects".into(), + search::entry::Config { + path_buf: "/path/to/projects".into(), hidden: false, max_depth: None, + ..Default::default() }, - SearchPath { - path: "/path/to/other_projects".into(), + search::entry::Config { + path_buf: "/path/to/other_projects".into(), hidden: true, max_depth: Some(1), + ..Default::default() }, - SearchPath { - path: "/path/to/another_project".into(), + search::entry::Config { + path_buf: "/path/to/another_project".into(), hidden: false, max_depth: Some(0), + ..Default::default() }, ]), } diff --git a/src/error.rs b/src/error.rs index ae5b227..5c0f26b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -9,5 +9,8 @@ pub enum Error { Ignore(#[from] ignore::Error), #[error(transparent)] + Git(#[from] git2::Error), + + #[error(transparent)] Other(#[from] anyhow::Error), } @@ -1,11 +1,11 @@ pub use crate::cli::Cli; pub use crate::config::Config; pub use crate::error::{Error, Result}; -pub use crate::projects::{Projects, SearchPath}; -pub use crate::sorter::{GitSorter, Sorter}; +pub use crate::project::Project; +pub use crate::search::{entry, Search}; mod cli; mod config; mod error; -mod projects; -mod sorter; +mod project; +mod search; diff --git a/src/main.rs b/src/main.rs index 2c2f980..ca08fa4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,7 @@ use anyhow::{Context, Result}; use clap::Parser; use figment::providers::{Env, Format, Toml}; -use projectr::{Cli, Config, GitSorter, Projects, Sorter}; -use std::path::PathBuf; +use projectr::{Cli, Config, Project, Search}; #[tracing::instrument] fn main() -> Result<()> { @@ -26,14 +25,14 @@ fn main() -> Result<()> { #[tracing::instrument] pub fn run(config: &Config) -> Result<()> { - let mut projects: Vec<PathBuf> = Projects::from_provider(config) + let mut projects: Vec<Box<dyn Project>> = Search::from_provider(config) .context("Failed to extract paths config")? .collect(); - GitSorter::sort(&mut projects); + projects.sort_unstable_by_key(|p| p.timestamp()); for project in projects { - println!("{}", project.to_string_lossy()) + println!("{}", project.to_path_buf().to_string_lossy()) } Ok(()) diff --git a/src/project.rs b/src/project.rs new file mode 100644 index 0000000..1188c88 --- /dev/null +++ b/src/project.rs @@ -0,0 +1,12 @@ +use std::{path::PathBuf, time::Duration}; + +pub use git::GitProject; +pub use path::PathProject; + +mod git; +mod path; + +pub trait Project { + fn timestamp(&self) -> Option<Duration>; + fn to_path_buf(&self) -> PathBuf; +} diff --git a/src/project/git.rs b/src/project/git.rs new file mode 100644 index 0000000..f1c4d5a --- /dev/null +++ b/src/project/git.rs @@ -0,0 +1,90 @@ +use crate::{Error, Project}; +use git2::{BranchType, Repository}; +use ignore::DirEntry; +use std::{cmp::Ordering, path::PathBuf, time::Duration}; + +#[derive(Debug, Clone)] +pub struct GitProject { + path_buf: PathBuf, + latest_commit: Option<Duration>, +} + +impl GitProject { + fn new(path_buf: PathBuf) -> Result<Self, Error> { + let repository = Repository::open(&path_buf)?; + let latest_commit = Self::latest_commit(&repository); + Ok(Self { + path_buf, + latest_commit, + }) + } + + fn latest_commit(repository: &Repository) -> Option<Duration> { + let mut branches = repository.branches(Some(BranchType::Local)).ok()?; + branches + .try_fold(0, |latest, branch| { + let branch = branch?.0; + + let name = branch + .name()? + .ok_or_else(|| git2::Error::from_str("Failed to find branch"))?; + + repository + .revparse_single(name)? + .peel_to_commit() + .map(|c| (c.time().seconds() as u64).max(latest)) + }) + .map(Duration::from_secs) + .ok() + } +} + +impl Project for GitProject { + fn timestamp(&self) -> Option<Duration> { + self.latest_commit + } + + fn to_path_buf(&self) -> PathBuf { + self.path_buf.to_owned() + } +} + +impl PartialEq for GitProject { + fn eq(&self, other: &Self) -> bool { + match (self.latest_commit, other.latest_commit) { + (Some(time), Some(other_time)) => time.eq(&other_time), + _ => false, + } + } +} + +impl PartialOrd for GitProject { + fn partial_cmp(&self, other: &Self) -> Option<Ordering> { + match (self.latest_commit, other.latest_commit) { + (Some(time), Some(other_time)) => time.partial_cmp(&other_time), + _ => None, + } + } +} + +impl TryFrom<PathBuf> for GitProject { + type Error = Error; + + fn try_from(value: PathBuf) -> Result<Self, Self::Error> { + Self::new(value) + } +} + +impl TryFrom<DirEntry> for GitProject { + type Error = Error; + + fn try_from(value: DirEntry) -> Result<Self, Self::Error> { + Self::new(value.into_path()) + } +} + +impl From<GitProject> for PathBuf { + fn from(value: GitProject) -> Self { + value.path_buf + } +} diff --git a/src/project/path.rs b/src/project/path.rs new file mode 100644 index 0000000..24dad9d --- /dev/null +++ b/src/project/path.rs @@ -0,0 +1,74 @@ +use ignore::DirEntry; +use std::{ + path::PathBuf, + time::{Duration, SystemTime}, +}; + +use crate::Project; + +#[derive(Debug, PartialEq, Eq, Clone, Default)] +pub struct PathProject { + path_buf: PathBuf, + timestamp: Option<Duration>, +} + +impl PathProject { + fn new(path_buf: PathBuf) -> Self { + let timestamp = path_buf + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()); + + Self { + timestamp, + path_buf, + } + } +} + +impl Project for PathProject { + fn timestamp(&self) -> Option<Duration> { + self.timestamp + } + + fn to_path_buf(&self) -> PathBuf { + self.path_buf.to_owned() + } +} + +impl TryFrom<(&String, DirEntry)> for PathProject { + type Error = String; + + fn try_from((pattern, dir_entry): (&String, DirEntry)) -> Result<Self, Self::Error> { + dir_entry + .path() + .join(pattern) + .exists() + .then(|| Self::from(dir_entry.to_owned())) + .ok_or_else(|| { + format!( + "Pattern `{:?}` not found in path: `{:?}`", + pattern, dir_entry + ) + }) + } +} + +impl From<PathBuf> for PathProject { + fn from(value: PathBuf) -> Self { + Self::new(value) + } +} + +impl From<DirEntry> for PathProject { + fn from(value: DirEntry) -> Self { + Self::new(value.into_path()) + } +} + +impl From<PathProject> for PathBuf { + fn from(value: PathProject) -> Self { + value.path_buf + } +} diff --git a/src/projects.rs b/src/projects.rs deleted file mode 100644 index 8819628..0000000 --- a/src/projects.rs +++ /dev/null @@ -1,117 +0,0 @@ -use crate::{Config, Result}; -use figment::Provider; -use ignore::Walk; -use std::{path::PathBuf, vec::IntoIter}; -use tracing::{error, info}; - -pub use entry::SearchPath; - -mod entry; - -pub struct Projects { - search_path_iter: IntoIter<SearchPath>, - walk: Option<Walk>, -} - -impl std::fmt::Debug for Projects { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Projects") - .field("paths_iter", &self.search_path_iter) - .finish_non_exhaustive() - } -} - -impl Projects { - pub fn new() -> Result<Self> { - Self::from_provider(Config::figment()) - } - - /// Extract `Config` from `provider` to construct new `Paths` - pub fn from_provider<T: Provider>(provider: T) -> Result<Self> { - Config::extract(&provider) - .map_err(Into::into) - .map(Into::into) - } -} - -impl From<Vec<SearchPath>> for Projects { - fn from(value: Vec<SearchPath>) -> Self { - Self { - search_path_iter: value.into_iter(), - walk: None, - } - } -} - -impl From<Config> for Projects { - fn from(value: Config) -> Self { - value.paths.into() - } -} - -impl Iterator for Projects { - type Item = PathBuf; - - #[tracing::instrument] - fn next(&mut self) -> Option<Self::Item> { - loop { - match self.walk.as_mut().and_then(|iter| iter.next()) { - Some(Ok(path)) if SearchPath::filter(&path) => return Some(path.into_path()), - Some(Ok(path)) => info!(?path, "Ignoring filtered path"), - Some(Err(err)) => error!(%err, "Ignoring errored path"), - None => { - self.walk = Some(self.search_path_iter.next()?.into()); - } - }; - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use std::fs; - - #[test] - fn test_iteration() { - let temp_dir = tempfile::Builder::new() - .tempdir() - .unwrap() - .path() - .to_owned(); - - let project_dir = temp_dir.join("project_dir"); - let project1 = temp_dir.join("project_dir/project1"); - let project2 = temp_dir.join("project_dir/project2"); - let project3 = temp_dir.join("project3"); - let project4 = temp_dir.join("subdir/project4"); - - let paths = Projects::from(Vec::from([ - SearchPath { - path: project_dir.to_owned(), - hidden: false, - max_depth: Some(1), - }, - SearchPath { - path: project3.to_owned(), - hidden: false, - max_depth: Some(0), - }, - SearchPath { - path: project4.to_owned(), - hidden: false, - max_depth: Some(0), - }, - ])); - - let mut path_bufs = Vec::from([project_dir, project1, project2, project3, project4]); - path_bufs.iter().try_for_each(fs::create_dir_all).unwrap(); - path_bufs.sort(); - - let mut results = paths.into_iter().collect::<Vec<PathBuf>>(); - results.sort(); - - assert_eq!(path_bufs, results); - } -} diff --git a/src/projects/entry.rs b/src/projects/entry.rs deleted file mode 100644 index 739ed83..0000000 --- a/src/projects/entry.rs +++ /dev/null @@ -1,76 +0,0 @@ -use ignore::{DirEntry, Walk, WalkBuilder}; -use serde::{Deserialize, Deserializer, Serialize}; -use std::{convert::Infallible, path::PathBuf, str::FromStr}; - -#[derive(Debug, PartialEq, Eq, Clone, Default, Serialize)] -#[serde(default)] -pub struct SearchPath { - pub path: PathBuf, - pub hidden: bool, - pub max_depth: Option<usize>, -} - -impl SearchPath { - pub fn filter(dir_entry: &DirEntry) -> bool { - dir_entry.path().join(".git").exists() - } -} - -impl From<PathBuf> for SearchPath { - fn from(path: PathBuf) -> Self { - Self { - path, - ..Default::default() - } - } -} - -impl From<SearchPath> for Walk { - fn from(value: SearchPath) -> Self { - WalkBuilder::new(value.path) - .standard_filters(true) - .max_depth(value.max_depth) - .hidden(!value.hidden) - .filter_entry(SearchPath::filter) - .build() - } -} - -impl FromStr for SearchPath { - type Err = Infallible; - - fn from_str(s: &str) -> Result<Self, Self::Err> { - s.parse().map(PathBuf::into) - } -} - -impl<'de> Deserialize<'de> for SearchPath { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(untagged)] - enum Variants { - String(String), - Struct { - path: PathBuf, - hidden: bool, - recurse: Option<usize>, - }, - } - - match Variants::deserialize(deserializer)? { - Variants::String(s) => s.parse().map_err(serde::de::Error::custom), - Variants::Struct { - path, - hidden, - recurse, - } => Ok(Self { - path, - hidden, - max_depth: recurse, - }), - } - } -} diff --git a/src/search.rs b/src/search.rs new file mode 100644 index 0000000..e39d67b --- /dev/null +++ b/src/search.rs @@ -0,0 +1,129 @@ +use figment::Provider; +use std::vec::IntoIter; + +use crate::{Config, Project, Result}; + +pub use entry::Entry; + +pub mod entry; + +pub struct Search { + iter: IntoIter<entry::Config>, + curr: Option<Entry>, +} + +impl std::fmt::Debug for Search { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Projects") + .field("paths_iter", &self.iter) + .finish_non_exhaustive() + } +} + +impl Search { + pub fn new() -> Result<Self> { + Self::from_provider(Config::figment()) + } + + /// Extract `Config` from `provider` to construct new `Paths` + pub fn from_provider<T: Provider>(provider: T) -> Result<Self> { + Config::extract(&provider) + .map_err(Into::into) + .map(|config| config.paths.into()) + } +} + +// impl<T> From<T> for Search +// where +// T: IntoIterator<Item = entry::Config, IntoIter = IntoIter<entry::Config>>, +// { +// fn from(value: T) -> Self { +// Self { +// iter: value.into_iter(), +// curr: None, +// } +// } +// } + +impl From<Vec<entry::Config>> for Search { + fn from(value: Vec<entry::Config>) -> Self { + Self { + iter: value.into_iter(), + curr: None, + } + } +} + +impl From<Config> for Search { + fn from(value: Config) -> Self { + value.paths.into() + } +} + +impl Iterator for Search { + type Item = Box<dyn Project>; + + #[tracing::instrument] + fn next(&mut self) -> Option<Self::Item> { + match self.curr.as_mut().and_then(|c| c.next()) { + Some(proj) => Some(proj), + None => { + self.curr = Some(self.iter.next()?.into()); + self.next() + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::{fs, path::PathBuf}; + + #[test] + fn test_iteration() { + let temp_dir = tempfile::Builder::new() + .tempdir() + .unwrap() + .path() + .to_owned(); + + let project_dir = temp_dir.join("project_dir"); + let project1 = temp_dir.join("project_dir/project1"); + let project2 = temp_dir.join("project_dir/project2"); + let project3 = temp_dir.join("project3"); + let project4 = temp_dir.join("subdir/project4"); + + let paths = Search::from(Vec::from([ + entry::Config { + path_buf: project_dir.to_owned(), + max_depth: Some(1), + ..Default::default() + }, + entry::Config { + path_buf: project3.to_owned(), + max_depth: Some(0), + ..Default::default() + }, + entry::Config { + path_buf: project4.to_owned(), + max_depth: Some(0), + ..Default::default() + }, + ])); + + let mut path_bufs = Vec::from([project_dir, project1, project2, project3, project4]); + path_bufs.iter().try_for_each(fs::create_dir_all).unwrap(); + path_bufs.sort(); + + let mut results = paths.into_iter().collect::<Vec<Box<dyn Project>>>(); + results.sort_unstable_by_key(|p| p.timestamp()); + let results = results + .into_iter() + .map(|p| p.to_path_buf()) + .collect::<Vec<PathBuf>>(); + + assert_eq!(path_bufs, results); + } +} diff --git a/src/search/entry.rs b/src/search/entry.rs new file mode 100644 index 0000000..6e94b35 --- /dev/null +++ b/src/search/entry.rs @@ -0,0 +1,57 @@ +use ignore::{DirEntry, Walk}; +use tracing::error; + +use crate::{ + project::{GitProject, PathProject}, + Project, +}; + +pub use config::Config; + +mod config; + +pub struct Entry { + config: Config, + iter: Walk, +} + +impl std::fmt::Debug for Entry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SearchPath") + .field("config", &self.config) + .finish() + } +} + +impl Entry { + pub fn match_project(&self, dir_entry: DirEntry) -> Option<Box<dyn Project>> { + if self.config.git { + if let Ok(git) = GitProject::try_from(dir_entry.to_owned()) { + return Some(Box::new(git)); + }; + }; + + if let Some(pattern) = &self.config.pattern { + if let Ok(proj) = PathProject::try_from((pattern, dir_entry)) { + return Some(Box::new(proj)); + }; + }; + + None + } +} + +impl Iterator for Entry { + type Item = Box<dyn Project>; + + #[tracing::instrument] + fn next(&mut self) -> Option<Self::Item> { + match self.iter.next()? { + Ok(dir_entry) => self.match_project(dir_entry), + Err(err) => { + error!(%err, "Ignoring errored path"); + self.next() + } + } + } +} diff --git a/src/search/entry/config.rs b/src/search/entry/config.rs new file mode 100644 index 0000000..d325b58 --- /dev/null +++ b/src/search/entry/config.rs @@ -0,0 +1,90 @@ +use ignore::WalkBuilder; +use serde::{Deserialize, Deserializer, Serialize}; +use std::{convert::Infallible, path::PathBuf, str::FromStr}; + +use super::Entry; + +#[derive(Debug, PartialEq, Eq, Clone, Default, Serialize)] +#[serde(default)] +pub struct Config { + pub path_buf: PathBuf, + pub hidden: bool, + pub max_depth: Option<usize>, + pub git: bool, + pub pattern: Option<String>, +} + +impl From<Config> for Entry { + fn from(config: Config) -> Self { + let iter = WalkBuilder::new(&config.path_buf) + .standard_filters(true) + .max_depth(config.max_depth) + .hidden(!config.hidden) + .build(); + Self { iter, config } + } +} + +impl From<PathBuf> for Config { + fn from(path_buf: PathBuf) -> Self { + Self { + path_buf, + ..Default::default() + } + } +} + +impl Config { + pub fn new(path_buf: PathBuf) -> Self { + Self { + path_buf, + ..Default::default() + } + } +} + +impl FromStr for Config { + type Err = Infallible; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + s.parse().map(PathBuf::into) + } +} + +// Custom deserialize impl to accept either string or struct +impl<'de> Deserialize<'de> for Config { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Variants { + String(String), + Struct { + path_buf: PathBuf, + hidden: bool, + max_depth: Option<usize>, + git: bool, + pattern: Option<String>, + }, + } + + match Variants::deserialize(deserializer)? { + Variants::String(s) => s.parse().map_err(serde::de::Error::custom), + Variants::Struct { + path_buf, + hidden, + max_depth, + git, + pattern, + } => Ok(Self { + path_buf, + hidden, + max_depth, + git, + pattern, + }), + } + } +} diff --git a/src/sorter.rs b/src/sorter.rs deleted file mode 100644 index 049b8d2..0000000 --- a/src/sorter.rs +++ /dev/null @@ -1,62 +0,0 @@ -use std::{cmp::Ordering, path::PathBuf}; - -use git2::{BranchType, Repository}; -use tracing::error; - -pub trait Sorter { - #[allow(clippy::ptr_arg)] - fn compare(a: &PathBuf, b: &PathBuf) -> Ordering; - - fn sort(vec: &mut Vec<PathBuf>) { - vec.sort_unstable_by(Self::compare); - } -} - -pub struct GitSorter; - -impl GitSorter { - fn get_commit(path: &PathBuf) -> Result<i64, git2::Error> { - let repo = Repository::open(path)?; - let mut branches = repo.branches(Some(BranchType::Local))?; - branches.try_fold(0, |latest, branch| { - let branch = branch?.0; - - let name = branch - .name()? - .ok_or_else(|| git2::Error::from_str("Failed to find branch"))?; - - repo.revparse_single(name)? - .peel_to_commit() - .map(|c| c.time().seconds().max(latest)) - }) - } -} - -impl Sorter for GitSorter { - fn compare(path_a: &PathBuf, path_b: &PathBuf) -> Ordering { - let commit_a = Self::get_commit(path_a); - let commit_b = Self::get_commit(path_b); - - match (commit_a, commit_b) { - (Ok(a), Ok(b)) => a.cmp(&b), - (Ok(_), Err(error_b)) => { - error!(?path_b, ?error_b, "Error while comparing git repos"); - Ordering::Less - } - (Err(error_a), Ok(_)) => { - error!(?path_a, ?error_a, "Error while comparing git repos"); - Ordering::Greater - } - (Err(error_a), Err(error_b)) => { - error!( - ?path_a, - ?error_a, - ?path_b, - ?error_b, - "Error while comparing git repos" - ); - Ordering::Equal - } - } - } -} |