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"
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",
]

View File

@ -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"

View File

@ -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<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(
deps: &Dependencies,
dir: &Path,
patterns: &[&str],
max_depth: usize,
) -> Result<HashMap<String, Vec<PathBuf>>> {
// 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::<HashMap<_, _>>();
// 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<Vec<PathBuf>> {
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 files contents, `false` otherwise.
pub async fn grep_file_in_memory(file: &Path, pattern: &str) -> Result<bool> {
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<bool> {
))
})?;
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))
}

View File

@ -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<String>,
pub paths: Vec<PathBuf>,
/// Maximum search depth
#[clap(short, long, default_value = "5")]

View File

@ -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<String>) -> 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<Self> {
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))
}
}

View File

@ -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<Vec<PathBuf>> {
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::<Vec<_>>();
// 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::<Vec<PathBuf>>();
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?");

View File

@ -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());
}