mirror of
https://github.com/kristoferssolo/project-finder.git
synced 2025-10-21 19:50:35 +00:00
docs: add docstrings
This commit is contained in:
parent
859bd1135e
commit
5647d63978
60
Cargo.lock
generated
60
Cargo.lock
generated
@ -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",
|
||||
]
|
||||
|
||||
10
Cargo.toml
10
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"
|
||||
|
||||
@ -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 file’s 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))
|
||||
}
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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?");
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user