docs: add docstrings

This commit is contained in:
Kristofers Solo 2025-03-25 14:37:17 +02:00
parent 859bd1135e
commit 5647d63978
7 changed files with 119 additions and 115 deletions

60
Cargo.lock generated
View File

@ -314,16 +314,6 @@ version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 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]] [[package]]
name = "log" name = "log"
version = "0.4.26" version = "0.4.26"
@ -387,29 +377,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" 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]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.16" version = "0.2.16"
@ -455,15 +422,6 @@ dependencies = [
"proc-macro2", "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]] [[package]]
name = "regex" name = "regex"
version = "1.11.1" version = "1.11.1"
@ -512,12 +470,6 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@ -551,16 +503,6 @@ version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" 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]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
@ -618,10 +560,8 @@ dependencies = [
"bytes", "bytes",
"libc", "libc",
"mio", "mio",
"parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",
"socket2",
"tokio-macros", "tokio-macros",
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]

View File

@ -19,7 +19,15 @@ clap = { version = "4.5", features = ["derive"] }
futures = "0.3" futures = "0.3"
regex = "1.11" regex = "1.11"
thiserror = "2.0" 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 = "0.1"
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
which = "7.0" which = "7.0"

View File

@ -5,6 +5,7 @@ use crate::{
use regex::{Regex, escape}; use regex::{Regex, escape};
use std::{ use std::{
collections::HashMap, collections::HashMap,
fmt::Display,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Stdio, process::Stdio,
}; };
@ -15,13 +16,35 @@ use tokio::{
}; };
use tracing::{debug, warn}; 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<E: Display>(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( pub async fn find_files(
deps: &Dependencies, deps: &Dependencies,
dir: &Path, dir: &Path,
patterns: &[&str], patterns: &[&str],
max_depth: usize, max_depth: usize,
) -> Result<HashMap<String, Vec<PathBuf>>> { ) -> Result<HashMap<String, Vec<PathBuf>>> {
// Build a regex pattern that matches any of the provided (literal) patterns.
let combined_patterns = format!( let combined_patterns = format!(
"({})", "({})",
patterns patterns
@ -32,7 +55,6 @@ pub async fn find_files(
); );
let mut cmd = Command::new(&deps.fd_path); let mut cmd = Command::new(&deps.fd_path);
cmd.arg("--hidden") cmd.arg("--hidden")
.arg("--no-ignore-vcs") .arg("--no-ignore-vcs")
.arg("--type") .arg("--type")
@ -49,23 +71,28 @@ pub async fn find_files(
ProjectFinderError::CommandExecutionFailed(format!("Failed to spawn fd: {e}")) 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(|| { let stdout = child.stdout.take().ok_or_else(|| {
ProjectFinderError::CommandExecutionFailed("Failed to capture stdout".into()) ProjectFinderError::CommandExecutionFailed("Failed to capture stdout".into())
})?; })?;
let reader = BufReader::new(stdout); let reader = BufReader::new(stdout);
let mut lines = reader.lines(); let mut lines = reader.lines();
// Prepare the results map with an empty vector for each pattern.
let mut results = patterns let mut results = patterns
.iter() .iter()
.map(|pattern| ((*pattern).to_string(), Vec::new())) .map(|pattern| ((*pattern).to_string(), Vec::new()))
.collect::<HashMap<_, _>>(); .collect::<HashMap<_, _>>();
// Process output as lines arrive. // Stream and process output as lines arrive.
while let Some(line) = lines.next_line().await.map_err(|e| { while let Some(line) = lines
ProjectFinderError::CommandExecutionFailed(format!("Failed to read stdout: {e}")) .next_line()
})? { .await
.map_err(|e| wrap_command_error("Failed to read stdout", e))?
{
let path = PathBuf::from(line); 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(file_name) = path.file_name().and_then(|f| f.to_str()) {
if let Some(entries) = results.get_mut(file_name) { if let Some(entries) = results.get_mut(file_name) {
entries.push(path); entries.push(path);
@ -73,10 +100,11 @@ pub async fn find_files(
} }
} }
// Ensure the process has finished. // Wait for the command to finish.
let status = child.wait().await.map_err(|e| { let status = child
ProjectFinderError::CommandExecutionFailed(format!("Failed to wait process: {e}")) .wait()
})?; .await
.map_err(|e| wrap_command_error("Failed to wait process", e))?;
if !status.success() { if !status.success() {
warn!("fd command exited with non-zero status: {status}"); warn!("fd command exited with non-zero status: {status}");
} }
@ -84,14 +112,26 @@ pub async fn find_files(
Ok(results) 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( pub async fn find_git_repos(
deps: &Dependencies, deps: &Dependencies,
dir: &Path, dir: &Path,
max_depth: usize, max_depth: usize,
) -> Result<Vec<PathBuf>> { ) -> Result<Vec<PathBuf>> {
let mut cmd = Command::new(&deps.fd_path); let mut cmd = Command::new(&deps.fd_path);
cmd.arg("--hidden") cmd.arg("--hidden")
.arg("--type") .arg("--type")
.arg("d") .arg("d")
@ -103,21 +143,22 @@ pub async fn find_git_repos(
debug!("Finding git repos in {}", dir.display()); debug!("Finding git repos in {}", dir.display());
let output = cmd.output().await.map_err(|e| { let output = cmd
ProjectFinderError::CommandExecutionFailed(format!("Failed to find git repositories: {e}")) .output()
})?; .await
.map_err(|e| wrap_command_error("Failed to find git repositories", e))?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
warn!("fd command failed: {}", stderr); warn!("fd command failed: {stderr}");
return Ok(Vec::new()); return Ok(Vec::new());
} }
let stdout = String::from_utf8(output.stdout).map_err(ProjectFinderError::Utf8Error)?; let stdout = String::from_utf8(output.stdout).map_err(ProjectFinderError::Utf8Error)?;
// For each found '.git' directory, return its parent directory.
let paths = stdout let paths = stdout
.lines() .lines()
// Convert .git directories to their parent (the actual repo root)
.filter_map(|line| { .filter_map(|line| {
let path = PathBuf::from(line); let path = PathBuf::from(line);
path.parent().map(std::path::Path::to_path_buf) path.parent().map(std::path::Path::to_path_buf)
@ -127,6 +168,16 @@ pub async fn find_git_repos(
Ok(paths) 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 files contents, `false` otherwise.
pub async fn grep_file_in_memory(file: &Path, pattern: &str) -> Result<bool> { pub async fn grep_file_in_memory(file: &Path, pattern: &str) -> Result<bool> {
let contents = read_to_string(file).await.map_err(|e| { let contents = read_to_string(file).await.map_err(|e| {
ProjectFinderError::CommandExecutionFailed(format!( ProjectFinderError::CommandExecutionFailed(format!(
@ -135,9 +186,8 @@ pub async fn grep_file_in_memory(file: &Path, pattern: &str) -> Result<bool> {
)) ))
})?; })?;
let re = Regex::new(pattern).map_err(|e| { let re = Regex::new(pattern)
ProjectFinderError::CommandExecutionFailed(format!("Invalid regex patter {pattern}: {e}")) .map_err(|e| wrap_command_error(&format!("Invalid regex patter {pattern}"), e))?;
})?;
Ok(re.is_match(&contents)) Ok(re.is_match(&contents))
} }

View File

@ -1,4 +1,5 @@
use clap::Parser; use clap::Parser;
use std::path::PathBuf;
#[derive(Debug, Parser, Clone)] #[derive(Debug, Parser, Clone)]
#[clap( #[clap(
@ -9,7 +10,7 @@ use clap::Parser;
pub struct Config { pub struct Config {
/// Directories to search for projects /// Directories to search for projects
#[clap(default_value = ".")] #[clap(default_value = ".")]
pub paths: Vec<String>, pub paths: Vec<PathBuf>,
/// Maximum search depth /// Maximum search depth
#[clap(short, long, default_value = "5")] #[clap(short, long, default_value = "5")]

View File

@ -2,29 +2,41 @@ use crate::errors::{ProjectFinderError, Result};
use tracing::info; use tracing::info;
use which::which; use which::which;
/// Represents external dependencies required by the application.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Dependencies { pub struct Dependencies {
pub fd_path: String, pub fd_path: String,
} }
impl Dependencies { impl Dependencies {
/// Creates a new instance of `Dependencies` from the given `fd` binary path.
pub fn new(fd_path: impl Into<String>) -> Self { pub fn new(fd_path: impl Into<String>) -> Self {
Self { Self {
fd_path: fd_path.into(), 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<Self> { pub fn check() -> Result<Self> {
info!("Checking dependencies..."); info!("Checking dependencies...");
let fd_path = which("fd").map_err(|_| { let fd_path = which("fd")
.map(|path| path.to_string_lossy().into_owned())
.map_err(|_| {
ProjectFinderError::DependencyNotFound( ProjectFinderError::DependencyNotFound(
"fd - install from https://github.com/sharkdp/fd".into(), "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))
} }
} }

View File

@ -38,10 +38,12 @@ const MARKER_PATTERNS: [&str; 13] = [
"bunfig.toml", "bunfig.toml",
]; ];
/// Check whether a given path exists.
async fn path_exists(path: &Path) -> bool { async fn path_exists(path: &Path) -> bool {
metadata(path).await.is_ok() metadata(path).await.is_ok()
} }
/// Struct responsible for scanning directories and detecting projects.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ProjectFinder { pub struct ProjectFinder {
config: Config, config: Config,
@ -52,6 +54,7 @@ pub struct ProjectFinder {
} }
impl ProjectFinder { impl ProjectFinder {
/// Create a new `ProjectFinder` instance.
pub fn new(config: Config, deps: Dependencies) -> Self { pub fn new(config: Config, deps: Dependencies) -> Self {
Self { Self {
config, config,
@ -62,25 +65,24 @@ impl ProjectFinder {
} }
} }
/// Find projects in the configured paths.
pub async fn find_projects(&self) -> Result<Vec<PathBuf>> { pub async fn find_projects(&self) -> Result<Vec<PathBuf>> {
let semaphore = Arc::new(Semaphore::new(8)); // Limit to 8 concurrent tasks 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 { for path in &self.config.paths {
let path_buf = PathBuf::from(path); if !path.is_dir() {
if !path_buf.is_dir() { return Err(ProjectFinderError::PathNotFound(path.clone()));
return Err(ProjectFinderError::PathNotFound(path_buf));
} }
if self.config.verbose { if self.config.verbose {
info!("Searching in: {}", path); info!("Searching in: {}", path.display());
} }
let finder_clone = self.clone(); let finder_clone = self.clone();
let path_clone = path_buf.clone(); let path_clone = path.clone();
let semaphore_clone = Arc::clone(&semaphore); let semaphore_clone = Arc::clone(&semaphore);
// Spawn a task for each directory with semaphore permit
let handle = spawn(async move { let handle = spawn(async move {
let _permit = semaphore_clone.acquire().await.map_err(|e| { let _permit = semaphore_clone.acquire().await.map_err(|e| {
ProjectFinderError::CommandExecutionFailed(format!( ProjectFinderError::CommandExecutionFailed(format!(
@ -92,6 +94,7 @@ impl ProjectFinder {
handles.push(handle); handles.push(handle);
} }
// Await all tasks and collect errors.
let handle_results = join_all(handles).await; let handle_results = join_all(handles).await;
let mut errors = handle_results let mut errors = handle_results
.into_iter() .into_iter()
@ -109,13 +112,12 @@ impl ProjectFinder {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
// Return first error if any occurred // If all tasks failed, return one of the errors.
// Only fail if all tasks failed
if !errors.is_empty() && errors.len() == self.config.paths.len() { if !errors.is_empty() && errors.len() == self.config.paths.len() {
return Err(errors.remove(0)); return Err(errors.remove(0));
} }
// Return sorted results // Gather discovered projects, sort and apply max_results limit, if set.
let mut projects = self let mut projects = self
.discovered_projects .discovered_projects
.read() .read()
@ -123,10 +125,7 @@ impl ProjectFinder {
.iter() .iter()
.cloned() .cloned()
.collect::<Vec<PathBuf>>(); .collect::<Vec<PathBuf>>();
projects.sort(); projects.sort();
// Apply max_results if set
if self.config.max_results > 0 && projects.len() > self.config.max_results { if self.config.max_results > 0 && projects.len() > self.config.max_results {
projects.truncate(self.config.max_results); projects.truncate(self.config.max_results);
} }
@ -134,16 +133,18 @@ impl ProjectFinder {
Ok(projects) Ok(projects)
} }
/// Process a single directory by scanning for git repositories and marker files.
async fn process_directory(&self, dir: &Path) -> Result<()> { 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?; 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?; let marker_map = find_files(&self.deps, dir, &MARKER_PATTERNS, self.config.depth).await?;
for (pattern, paths) in marker_map { for (pattern, paths) in marker_map {
for path in paths { for path in paths {
if let Some(parent_dir) = path.parent() { if let Some(parent_dir) = path.parent() {
@ -155,6 +156,7 @@ impl ProjectFinder {
Ok(()) Ok(())
} }
/// Process a marker file found in a directory.
async fn process_marker(&self, dir: &Path, marker_name: &str) -> Result<()> { async fn process_marker(&self, dir: &Path, marker_name: &str) -> Result<()> {
// Determine marker type // Determine marker type
let marker_type = marker_name.parse().expect("How did we get here?"); let marker_type = marker_name.parse().expect("How did we get here?");

View File

@ -34,14 +34,6 @@ async fn main() {
Ok(deps) => deps, Ok(deps) => deps,
Err(e) => { Err(e) => {
error!("{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); exit(1);
} }
}; };
@ -51,7 +43,6 @@ async fn main() {
match finder.find_projects().await { match finder.find_projects().await {
Ok(projects) => { Ok(projects) => {
// Output results
for project in projects { for project in projects {
println!("{}", project.display()); println!("{}", project.display());
} }