diff --git a/Cargo.lock b/Cargo.lock index e4d5fc2..0de696a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,16 +314,6 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" -[[package]] -name = "lock_api" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" -dependencies = [ - "autocfg", - "scopeguard", -] - [[package]] name = "log" version = "0.4.26" @@ -387,29 +377,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" -[[package]] -name = "parking_lot" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets", -] - [[package]] name = "pin-project-lite" version = "0.2.16" @@ -455,15 +422,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "redox_syscall" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" -dependencies = [ - "bitflags", -] - [[package]] name = "regex" version = "1.11.1" @@ -512,12 +470,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "sharded-slab" version = "0.1.7" @@ -551,16 +503,6 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" -[[package]] -name = "socket2" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "strsim" version = "0.11.1" @@ -618,10 +560,8 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", "tokio-macros", "windows-sys 0.52.0", ] diff --git a/Cargo.toml b/Cargo.toml index f8e86d5..0dd2fbe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,15 @@ clap = { version = "4.5", features = ["derive"] } futures = "0.3" regex = "1.11" thiserror = "2.0" -tokio = { version = "1", features = ["full"] } +tokio = { version = "1.44", features = [ + "fs", + "io-util", + "macros", + "process", + "rt", + "rt-multi-thread", + "sync", +] } tracing = "0.1" tracing-subscriber = "0.3" which = "7.0" diff --git a/src/commands.rs b/src/commands.rs index 44f1ae6..38c3c85 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -5,6 +5,7 @@ use crate::{ use regex::{Regex, escape}; use std::{ collections::HashMap, + fmt::Display, path::{Path, PathBuf}, process::Stdio, }; @@ -15,13 +16,35 @@ use tokio::{ }; use tracing::{debug, warn}; -/// Run fd command to find files and directories +/// Helper to wrap command errors in a uniform way. +fn wrap_command_error(action: &str, err: E) -> ProjectFinderError { + ProjectFinderError::CommandExecutionFailed(format!("{action}: {err}")) +} + +/// Run the `fd` command to find files matching one or more literal patterns. +/// +/// The function builds a combined regex pattern from the list of patterns, runs the +/// command asynchronously, and collects matching file paths in a map keyed by the literal +/// file name. +/// +/// # Arguments +/// +/// - `deps`: Dependencies hold the path to the `fd` binary. +/// - `dir`: The directory in which to search. +/// - `patterns`: A list of file name patterns (literals) to match. +/// - `max_depth`: The maximum directory depth for the search. +/// +/// # Returns +/// +/// A map where each key is one of the patterns and the value is the list of matching +/// file paths. pub async fn find_files( deps: &Dependencies, dir: &Path, patterns: &[&str], max_depth: usize, ) -> Result>> { + // Build a regex pattern that matches any of the provided (literal) patterns. let combined_patterns = format!( "({})", patterns @@ -32,7 +55,6 @@ pub async fn find_files( ); let mut cmd = Command::new(&deps.fd_path); - cmd.arg("--hidden") .arg("--no-ignore-vcs") .arg("--type") @@ -49,23 +71,28 @@ pub async fn find_files( ProjectFinderError::CommandExecutionFailed(format!("Failed to spawn fd: {e}")) })?; - // Take the stdout and wrap it with a buffered reader. + // Capture stdout and wrap it with a buffered reader. let stdout = child.stdout.take().ok_or_else(|| { ProjectFinderError::CommandExecutionFailed("Failed to capture stdout".into()) })?; let reader = BufReader::new(stdout); let mut lines = reader.lines(); + // Prepare the results map with an empty vector for each pattern. let mut results = patterns .iter() .map(|pattern| ((*pattern).to_string(), Vec::new())) .collect::>(); - // Process output as lines arrive. - while let Some(line) = lines.next_line().await.map_err(|e| { - ProjectFinderError::CommandExecutionFailed(format!("Failed to read stdout: {e}")) - })? { + // Stream and process output as lines arrive. + while let Some(line) = lines + .next_line() + .await + .map_err(|e| wrap_command_error("Failed to read stdout", e))? + { let path = PathBuf::from(line); + // For each found file, only add it if its file name exactly matches one + // of the provided patterns. if let Some(file_name) = path.file_name().and_then(|f| f.to_str()) { if let Some(entries) = results.get_mut(file_name) { entries.push(path); @@ -73,10 +100,11 @@ pub async fn find_files( } } - // Ensure the process has finished. - let status = child.wait().await.map_err(|e| { - ProjectFinderError::CommandExecutionFailed(format!("Failed to wait process: {e}")) - })?; + // Wait for the command to finish. + let status = child + .wait() + .await + .map_err(|e| wrap_command_error("Failed to wait process", e))?; if !status.success() { warn!("fd command exited with non-zero status: {status}"); } @@ -84,14 +112,26 @@ pub async fn find_files( Ok(results) } -/// Find Git repositories +/// Find Git repositories by searching for '.git' directories. +/// +/// This function invokes the `fd` command with the pattern '^.git$'. For each +/// found directory, it returns the parent path (the Git repository root). +/// +/// # Arguments +/// +/// - `deps`: Dependencies containing the path to the `fd` binary. +/// - `dir`: The directory to search for Git repositories. +/// - `max_depth`: The maximum directory depth to search. +/// +/// # Returns +/// +/// A vector of paths representing the roots of Git repositories. pub async fn find_git_repos( deps: &Dependencies, dir: &Path, max_depth: usize, ) -> Result> { let mut cmd = Command::new(&deps.fd_path); - cmd.arg("--hidden") .arg("--type") .arg("d") @@ -103,21 +143,22 @@ pub async fn find_git_repos( debug!("Finding git repos in {}", dir.display()); - let output = cmd.output().await.map_err(|e| { - ProjectFinderError::CommandExecutionFailed(format!("Failed to find git repositories: {e}")) - })?; + let output = cmd + .output() + .await + .map_err(|e| wrap_command_error("Failed to find git repositories", e))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - warn!("fd command failed: {}", stderr); + warn!("fd command failed: {stderr}"); return Ok(Vec::new()); } let stdout = String::from_utf8(output.stdout).map_err(ProjectFinderError::Utf8Error)?; + // For each found '.git' directory, return its parent directory. let paths = stdout .lines() - // Convert .git directories to their parent (the actual repo root) .filter_map(|line| { let path = PathBuf::from(line); path.parent().map(std::path::Path::to_path_buf) @@ -127,6 +168,16 @@ pub async fn find_git_repos( Ok(paths) } +/// Read a file into memory and check if it contains any match of the provided regex. +/// +/// # Arguments +/// +/// - `file`: The file to read. +/// - `pattern`: The regex pattern to search for. +/// +/// # Returns +/// +/// `true` if the regex matches the file’s contents, `false` otherwise. pub async fn grep_file_in_memory(file: &Path, pattern: &str) -> Result { let contents = read_to_string(file).await.map_err(|e| { ProjectFinderError::CommandExecutionFailed(format!( @@ -135,9 +186,8 @@ pub async fn grep_file_in_memory(file: &Path, pattern: &str) -> Result { )) })?; - let re = Regex::new(pattern).map_err(|e| { - ProjectFinderError::CommandExecutionFailed(format!("Invalid regex patter {pattern}: {e}")) - })?; + let re = Regex::new(pattern) + .map_err(|e| wrap_command_error(&format!("Invalid regex patter {pattern}"), e))?; Ok(re.is_match(&contents)) } diff --git a/src/config.rs b/src/config.rs index 6e0d8b5..2f21612 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,5 @@ use clap::Parser; +use std::path::PathBuf; #[derive(Debug, Parser, Clone)] #[clap( @@ -9,7 +10,7 @@ use clap::Parser; pub struct Config { /// Directories to search for projects #[clap(default_value = ".")] - pub paths: Vec, + pub paths: Vec, /// Maximum search depth #[clap(short, long, default_value = "5")] diff --git a/src/dependencies.rs b/src/dependencies.rs index ca601b5..a517cc8 100644 --- a/src/dependencies.rs +++ b/src/dependencies.rs @@ -2,29 +2,41 @@ use crate::errors::{ProjectFinderError, Result}; use tracing::info; use which::which; +/// Represents external dependencies required by the application. #[derive(Debug, Clone)] pub struct Dependencies { pub fd_path: String, } impl Dependencies { + /// Creates a new instance of `Dependencies` from the given `fd` binary path. pub fn new(fd_path: impl Into) -> Self { Self { fd_path: fd_path.into(), } } + /// Checks if all required dependencies are available, returning an instance of + /// `Dependencies` with the paths set appropriately. + /// + /// At the moment, this only verifies that the `fd` binary is available. + /// + /// # Errors + /// + /// Returns a `ProjectFinderError::DependencyNotFound` error if `fd` is not found. pub fn check() -> Result { info!("Checking dependencies..."); - let fd_path = which("fd").map_err(|_| { - ProjectFinderError::DependencyNotFound( - "fd - install from https://github.com/sharkdp/fd".into(), - ) - })?; + let fd_path = which("fd") + .map(|path| path.to_string_lossy().into_owned()) + .map_err(|_| { + ProjectFinderError::DependencyNotFound( + "fd - install from https://github.com/sharkdp/fd".into(), + ) + })?; - info!("Found fd at: {}", fd_path.display()); + info!("Found fd at: {fd_path}"); - Ok(Self::new(fd_path.to_string_lossy())) + Ok(Self::new(fd_path)) } } diff --git a/src/finder.rs b/src/finder.rs index 4051d17..2a4efee 100644 --- a/src/finder.rs +++ b/src/finder.rs @@ -38,10 +38,12 @@ const MARKER_PATTERNS: [&str; 13] = [ "bunfig.toml", ]; +/// Check whether a given path exists. async fn path_exists(path: &Path) -> bool { metadata(path).await.is_ok() } +/// Struct responsible for scanning directories and detecting projects. #[derive(Debug, Clone)] pub struct ProjectFinder { config: Config, @@ -52,6 +54,7 @@ pub struct ProjectFinder { } impl ProjectFinder { + /// Create a new `ProjectFinder` instance. pub fn new(config: Config, deps: Dependencies) -> Self { Self { config, @@ -62,25 +65,24 @@ impl ProjectFinder { } } + /// Find projects in the configured paths. pub async fn find_projects(&self) -> Result> { let semaphore = Arc::new(Semaphore::new(8)); // Limit to 8 concurrent tasks - let mut handles = vec![]; + let mut handles = Vec::new(); for path in &self.config.paths { - let path_buf = PathBuf::from(path); - if !path_buf.is_dir() { - return Err(ProjectFinderError::PathNotFound(path_buf)); + if !path.is_dir() { + return Err(ProjectFinderError::PathNotFound(path.clone())); } if self.config.verbose { - info!("Searching in: {}", path); + info!("Searching in: {}", path.display()); } let finder_clone = self.clone(); - let path_clone = path_buf.clone(); + let path_clone = path.clone(); let semaphore_clone = Arc::clone(&semaphore); - // Spawn a task for each directory with semaphore permit let handle = spawn(async move { let _permit = semaphore_clone.acquire().await.map_err(|e| { ProjectFinderError::CommandExecutionFailed(format!( @@ -92,6 +94,7 @@ impl ProjectFinder { handles.push(handle); } + // Await all tasks and collect errors. let handle_results = join_all(handles).await; let mut errors = handle_results .into_iter() @@ -109,13 +112,12 @@ impl ProjectFinder { }) .collect::>(); - // Return first error if any occurred - // Only fail if all tasks failed + // If all tasks failed, return one of the errors. if !errors.is_empty() && errors.len() == self.config.paths.len() { return Err(errors.remove(0)); } - // Return sorted results + // Gather discovered projects, sort and apply max_results limit, if set. let mut projects = self .discovered_projects .read() @@ -123,10 +125,7 @@ impl ProjectFinder { .iter() .cloned() .collect::>(); - projects.sort(); - - // Apply max_results if set if self.config.max_results > 0 && projects.len() > self.config.max_results { projects.truncate(self.config.max_results); } @@ -134,16 +133,18 @@ impl ProjectFinder { Ok(projects) } + /// Process a single directory by scanning for git repositories and marker files. async fn process_directory(&self, dir: &Path) -> Result<()> { - // First find all git repositories (usually the most reliable project indicators) + // Look for git repositories first. let git_repos = find_git_repos(&self.deps, dir, self.config.depth).await?; { - self.discovered_projects.write().await.extend(git_repos); + let mut projects = self.discovered_projects.write().await; + projects.extend(git_repos); } + // Look for marker files. let marker_map = find_files(&self.deps, dir, &MARKER_PATTERNS, self.config.depth).await?; - for (pattern, paths) in marker_map { for path in paths { if let Some(parent_dir) = path.parent() { @@ -155,6 +156,7 @@ impl ProjectFinder { Ok(()) } + /// Process a marker file found in a directory. async fn process_marker(&self, dir: &Path, marker_name: &str) -> Result<()> { // Determine marker type let marker_type = marker_name.parse().expect("How did we get here?"); diff --git a/src/main.rs b/src/main.rs index c041e6e..32a2f91 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,14 +34,6 @@ async fn main() { Ok(deps) => deps, Err(e) => { error!("{e}"); - eprintln!("Error: {e}"); - eprintln!( - "This tool requires both 'fd' and 'ripgrep' (rg) to be installed and available in your PATH." - ); - eprintln!("Please install the missing dependencies and try again."); - eprintln!("\nInstallation instructions:"); - eprintln!(" fd: https://github.com/sharkdp/fd#installation"); - eprintln!(" ripgrep: https://github.com/BurntSushi/ripgrep#installation"); exit(1); } }; @@ -51,7 +43,6 @@ async fn main() { match finder.find_projects().await { Ok(projects) => { - // Output results for project in projects { println!("{}", project.display()); }