use std::collections::BTreeSet;
#[cfg(test)]
use std::fs;
use std::path::Path;
#[cfg(test)]
use std::path::PathBuf;
use crate::domain::{
CodeMonorepoWorkspace, CodeMonorepoWorkspaceFormat, CodeWorkspaceDetectionConfig,
};
mod cargo_workspace;
mod go_work;
mod pnpm_workspace;
pub(in crate::code) trait WorkspaceSource {
fn root_path(&self) -> &Path;
fn read_to_string(&self, relative_path: &str) -> Option<String>;
fn child_dirs(&self, relative_dir: &str) -> Vec<String>;
fn descendant_dirs_containing_file(
&self,
relative_dir: &str,
file_name: &str,
directory_limit: usize,
entry_limit: usize,
) -> Vec<String> {
bounded_descendant_dirs_containing_file(
self,
relative_dir,
file_name,
directory_limit,
entry_limit,
)
}
}
fn bounded_descendant_dirs_containing_file<S: WorkspaceSource + ?Sized>(
source: &S,
relative_dir: &str,
file_name: &str,
directory_limit: usize,
entry_limit: usize,
) -> Vec<String> {
if directory_limit == 0 || entry_limit == 0 || file_name.trim().is_empty() {
return Vec::new();
}
let mut result = BTreeSet::new();
let mut queued = BTreeSet::new();
let mut stack = Vec::new();
let mut observed_entries = 0usize;
queue_limited_child_dirs(
source,
relative_dir,
directory_limit,
entry_limit,
&mut observed_entries,
&mut queued,
&mut stack,
);
let mut visited_dirs = 0usize;
while let Some(dir) = stack.pop() {
if visited_dirs >= directory_limit {
break;
}
visited_dirs += 1;
if source
.read_to_string(&join_relative_path(&dir, file_name))
.is_some()
&& result.insert(dir.clone())
&& result.len() >= directory_limit
{
break;
}
queue_limited_child_dirs(
source,
&dir,
directory_limit,
entry_limit,
&mut observed_entries,
&mut queued,
&mut stack,
);
}
result.into_iter().collect()
}
fn queue_limited_child_dirs<S: WorkspaceSource + ?Sized>(
source: &S,
relative_dir: &str,
directory_limit: usize,
entry_limit: usize,
observed_entries: &mut usize,
queued: &mut BTreeSet<String>,
stack: &mut Vec<String>,
) {
let remaining = directory_limit
.saturating_sub(queued.len())
.min(entry_limit.saturating_sub(*observed_entries));
if remaining == 0 {
return;
}
for child in source.child_dirs(relative_dir).into_iter().take(remaining) {
*observed_entries += 1;
if queued.insert(child.clone()) {
stack.push(child);
}
}
}
#[cfg(test)]
pub(in crate::code) struct FilesystemWorkspaceSource<'a> {
root_path: &'a Path,
}
#[cfg(test)]
impl<'a> FilesystemWorkspaceSource<'a> {
pub(in crate::code) fn new(root_path: &'a Path) -> Self {
Self { root_path }
}
}
#[cfg(test)]
impl WorkspaceSource for FilesystemWorkspaceSource<'_> {
fn root_path(&self) -> &Path {
self.root_path
}
fn read_to_string(&self, relative_path: &str) -> Option<String> {
fs::read_to_string(self.root_path.join(relative_path)).ok()
}
fn child_dirs(&self, relative_dir: &str) -> Vec<String> {
let parent = self.root_path.join(relative_dir);
let Ok(entries) = fs::read_dir(parent) else {
return Vec::new();
};
let mut dirs = entries
.filter_map(Result::ok)
.filter_map(|entry| {
entry
.file_type()
.ok()
.filter(|file_type| file_type.is_dir())
.map(|_| entry.file_name())
})
.map(|name| join_relative_path(relative_dir, &PathBuf::from(name).to_string_lossy()))
.collect::<Vec<_>>();
dirs.sort();
dirs
}
}
#[cfg(test)]
fn detect_workspaces(
root_path: &Path,
config: &CodeWorkspaceDetectionConfig,
) -> Vec<CodeMonorepoWorkspace> {
let source = FilesystemWorkspaceSource::new(root_path);
detect_workspaces_from_source(&source, config)
}
pub(in crate::code) fn detect_workspaces_from_source(
source: &dyn WorkspaceSource,
config: &CodeWorkspaceDetectionConfig,
) -> Vec<CodeMonorepoWorkspace> {
if !config.enabled {
return Vec::new();
}
let mut workspaces = Vec::new();
for format in [
CodeMonorepoWorkspaceFormat::Pnpm,
CodeMonorepoWorkspaceFormat::GoModules,
CodeMonorepoWorkspaceFormat::CargoWorkspace,
] {
if !config.supported_formats.contains(&format) {
continue;
}
match format {
CodeMonorepoWorkspaceFormat::Pnpm => {
if let Some(members) = pnpm_workspace::detect_pnpm_workspace(source) {
workspaces.push(CodeMonorepoWorkspace {
format: CodeMonorepoWorkspaceFormat::Pnpm,
root_path: source.root_path().display().to_string(),
workspace_file_path: source
.root_path()
.join("pnpm-workspace.yaml")
.display()
.to_string(),
members,
});
}
}
CodeMonorepoWorkspaceFormat::GoModules => {
if let Some(members) = go_work::detect_go_work(source) {
workspaces.push(CodeMonorepoWorkspace {
format: CodeMonorepoWorkspaceFormat::GoModules,
root_path: source.root_path().display().to_string(),
workspace_file_path: source
.root_path()
.join("go.work")
.display()
.to_string(),
members,
});
}
}
CodeMonorepoWorkspaceFormat::CargoWorkspace => {
if let Some(members) = cargo_workspace::detect_cargo_workspace(source) {
workspaces.push(CodeMonorepoWorkspace {
format: CodeMonorepoWorkspaceFormat::CargoWorkspace,
root_path: source.root_path().display().to_string(),
workspace_file_path: source
.root_path()
.join("Cargo.toml")
.display()
.to_string(),
members,
});
}
}
}
}
workspaces
}
pub(super) fn join_relative_path(parent: &str, child: &str) -> String {
let parent = parent.trim().trim_matches('/');
let child = child.trim().trim_matches('/');
if parent.is_empty() || parent == "." {
child.to_owned()
} else if child.is_empty() {
parent.to_owned()
} else {
format!("{parent}/{child}")
}
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;