use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RootKind {
GitRepo,
HgRepo,
SvnRepo,
FossilRepo,
BzrRepo,
CargoWorkspace,
CargoPackage,
NpmPackage,
PnpmWorkspace,
YarnWorkspace,
LernaWorkspace,
TurboWorkspace,
PythonProject,
GoWorkspace,
GoModule,
MavenProject,
GradleWorkspace,
GradleProject,
CMakeProject,
RubyProject,
PhpProject,
ElixirProject,
DotnetProject,
MdBook,
MkDocs,
Jekyll,
Sphinx,
Docusaurus,
Hugo,
Gatsby,
Astro,
DocFx,
DvcDataset,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RootCategory {
Vcs,
Workspace,
Package,
Docs,
Data,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Confidence {
Weak,
Medium,
Strong,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct DiscoveredRoot {
pub path: PathBuf,
pub kinds: Vec<RootKind>,
pub confidence: Confidence,
}
#[derive(Clone, Debug)]
pub struct DiscoverOptions {
pub max_depth: usize,
pub min_confidence: Confidence,
pub include_kinds: Option<BTreeSet<RootKind>>,
pub expand_workspaces: bool,
pub nested_vcs: bool,
}
impl Default for DiscoverOptions {
fn default() -> Self {
Self {
max_depth: 8,
min_confidence: Confidence::Medium,
include_kinds: None,
expand_workspaces: false,
nested_vcs: false,
}
}
}
impl RootKind {
pub fn category(self) -> RootCategory {
use RootKind::*;
match self {
GitRepo | HgRepo | SvnRepo | FossilRepo | BzrRepo => RootCategory::Vcs,
CargoWorkspace | PnpmWorkspace | YarnWorkspace | LernaWorkspace | TurboWorkspace
| GoWorkspace | GradleWorkspace => RootCategory::Workspace,
CargoPackage | NpmPackage | PythonProject | GoModule | MavenProject | GradleProject
| CMakeProject | RubyProject | PhpProject | ElixirProject | DotnetProject => {
RootCategory::Package
}
MdBook | MkDocs | Jekyll | Sphinx | Docusaurus | Hugo | Gatsby | Astro | DocFx => {
RootCategory::Docs
}
DvcDataset => RootCategory::Data,
}
}
pub fn token(self) -> &'static str {
use RootKind::*;
match self {
GitRepo => "git_repo",
HgRepo => "hg_repo",
SvnRepo => "svn_repo",
FossilRepo => "fossil_repo",
BzrRepo => "bzr_repo",
CargoWorkspace => "cargo_workspace",
CargoPackage => "cargo_package",
NpmPackage => "npm_package",
PnpmWorkspace => "pnpm_workspace",
YarnWorkspace => "yarn_workspace",
LernaWorkspace => "lerna_workspace",
TurboWorkspace => "turbo_workspace",
PythonProject => "python_project",
GoWorkspace => "go_workspace",
GoModule => "go_module",
MavenProject => "maven_project",
GradleWorkspace => "gradle_workspace",
GradleProject => "gradle_project",
CMakeProject => "cmake_project",
RubyProject => "ruby_project",
PhpProject => "php_project",
ElixirProject => "elixir_project",
DotnetProject => "dotnet_project",
MdBook => "mdbook",
MkDocs => "mkdocs",
Jekyll => "jekyll",
Sphinx => "sphinx",
Docusaurus => "docusaurus",
Hugo => "hugo",
Gatsby => "gatsby",
Astro => "astro",
DocFx => "docfx",
DvcDataset => "dvc_dataset",
}
}
pub fn from_token(token: &str) -> Option<Self> {
let normalized = token.trim().to_ascii_lowercase().replace('-', "_");
match normalized.as_str() {
"git" | "git_repo" => Some(Self::GitRepo),
"hg" | "mercurial" | "hg_repo" => Some(Self::HgRepo),
"svn" | "subversion" | "svn_repo" => Some(Self::SvnRepo),
"fossil" | "fossil_repo" => Some(Self::FossilRepo),
"bzr" | "bazaar" | "bzr_repo" => Some(Self::BzrRepo),
"cargo" | "cargo_package" => Some(Self::CargoPackage),
"cargo_workspace" => Some(Self::CargoWorkspace),
"npm" | "npm_package" | "node" => Some(Self::NpmPackage),
"pnpm" | "pnpm_workspace" => Some(Self::PnpmWorkspace),
"yarn" | "yarn_workspace" => Some(Self::YarnWorkspace),
"lerna" | "lerna_workspace" => Some(Self::LernaWorkspace),
"turbo" | "turbo_workspace" => Some(Self::TurboWorkspace),
"python" | "pyproject" | "python_project" => Some(Self::PythonProject),
"go" | "go_module" => Some(Self::GoModule),
"go_workspace" => Some(Self::GoWorkspace),
"maven" | "maven_project" => Some(Self::MavenProject),
"gradle" | "gradle_project" => Some(Self::GradleProject),
"gradle_workspace" => Some(Self::GradleWorkspace),
"cmake" | "cmake_project" => Some(Self::CMakeProject),
"ruby" | "ruby_project" => Some(Self::RubyProject),
"php" | "composer" | "php_project" => Some(Self::PhpProject),
"elixir" | "mix" | "elixir_project" => Some(Self::ElixirProject),
"dotnet" | "csharp" | "dotnet_project" => Some(Self::DotnetProject),
"mdbook" => Some(Self::MdBook),
"mkdocs" => Some(Self::MkDocs),
"jekyll" => Some(Self::Jekyll),
"sphinx" => Some(Self::Sphinx),
"docusaurus" => Some(Self::Docusaurus),
"hugo" => Some(Self::Hugo),
"gatsby" => Some(Self::Gatsby),
"astro" => Some(Self::Astro),
"docfx" => Some(Self::DocFx),
"dvc" | "dvc_dataset" => Some(Self::DvcDataset),
_ => None,
}
}
}
impl Confidence {
pub fn token(self) -> &'static str {
match self {
Self::Weak => "weak",
Self::Medium => "medium",
Self::Strong => "strong",
}
}
pub fn from_token(token: &str) -> Option<Self> {
match token.trim().to_ascii_lowercase().as_str() {
"weak" => Some(Self::Weak),
"medium" => Some(Self::Medium),
"strong" => Some(Self::Strong),
_ => None,
}
}
}
pub fn discover_roots(
path: impl AsRef<Path>,
opts: &DiscoverOptions,
) -> Result<Vec<DiscoveredRoot>> {
let input = path.as_ref();
let canonical = fs::canonicalize(input)
.with_context(|| format!("failed to resolve `{}`", input.display()))?;
if !canonical.is_dir() {
bail!("`{}` is not a directory", canonical.display());
}
let mut results: Vec<DiscoveredRoot> = Vec::new();
let mut stack: Vec<(PathBuf, usize)> = vec![(canonical, 0)];
while let Some((dir, depth)) = stack.pop() {
match classify_dir(&dir) {
DirClass::Skip => continue,
DirClass::Root { kinds, confidence } => {
let is_workspace = kinds
.iter()
.any(|kind| kind.category() == RootCategory::Workspace);
let is_vcs = kinds
.iter()
.any(|kind| kind.category() == RootCategory::Vcs);
results.push(DiscoveredRoot {
path: dir.clone(),
kinds,
confidence,
});
let descend =
(is_workspace && opts.expand_workspaces) || (is_vcs && opts.nested_vcs);
if !descend || depth >= opts.max_depth {
continue;
}
push_children(&dir, depth, &mut stack);
}
DirClass::Container => {
if depth >= opts.max_depth {
continue;
}
push_children(&dir, depth, &mut stack);
}
}
}
results.retain(|root| root.confidence >= opts.min_confidence);
if let Some(filter) = &opts.include_kinds {
results.retain(|root| root.kinds.iter().any(|kind| filter.contains(kind)));
}
results.sort_by(|left, right| left.path.cmp(&right.path));
Ok(results)
}
pub fn summarize_roots(roots: &[DiscoveredRoot]) -> DiscoverSummary {
let mut by_kind: BTreeMap<RootKind, usize> = BTreeMap::new();
let mut by_confidence: BTreeMap<Confidence, usize> = BTreeMap::new();
for root in roots {
*by_confidence.entry(root.confidence).or_insert(0) += 1;
for kind in &root.kinds {
*by_kind.entry(*kind).or_insert(0) += 1;
}
}
DiscoverSummary {
total: roots.len(),
by_kind,
by_confidence,
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DiscoverSummary {
pub total: usize,
pub by_kind: BTreeMap<RootKind, usize>,
pub by_confidence: BTreeMap<Confidence, usize>,
}
pub fn render_discover_markdown(
root: &Path,
roots: &[DiscoveredRoot],
summary: &DiscoverSummary,
) -> String {
let mut out = String::new();
out.push_str("# Projd Discover Report\n\n");
out.push_str(&format!("- Root: `{}`\n", root.display()));
out.push_str(&format!("- Total project roots: {}\n", summary.total));
if !summary.by_confidence.is_empty() {
let parts: Vec<String> = [Confidence::Strong, Confidence::Medium, Confidence::Weak]
.iter()
.filter_map(|level| {
summary
.by_confidence
.get(level)
.map(|count| format!("{}: {}", level.token(), count))
})
.collect();
if !parts.is_empty() {
out.push_str(&format!("- Confidence: {}\n", parts.join(" / ")));
}
}
if !summary.by_kind.is_empty() {
out.push_str("- Kinds:\n");
for (kind, count) in &summary.by_kind {
out.push_str(&format!(" - {}: {}\n", kind.token(), count));
}
}
out.push('\n');
if roots.is_empty() {
out.push_str("No project roots found.\n");
return out;
}
out.push_str("| Path | Kinds | Confidence | Category |\n");
out.push_str("| --- | --- | --- | --- |\n");
for entry in roots {
let rel = relative_display(root, &entry.path);
let kinds: Vec<&'static str> = entry.kinds.iter().map(|k| k.token()).collect();
let mut cats: BTreeSet<&'static str> = BTreeSet::new();
for kind in &entry.kinds {
cats.insert(category_token(kind.category()));
}
out.push_str(&format!(
"| `{}` | {} | {} | {} |\n",
rel,
kinds.join(", "),
entry.confidence.token(),
cats.into_iter().collect::<Vec<_>>().join(", "),
));
}
out
}
pub fn render_discover_json(
root: &Path,
roots: &[DiscoveredRoot],
summary: &DiscoverSummary,
) -> Result<String> {
#[derive(Serialize)]
struct ReportView<'a> {
root: &'a Path,
total: usize,
by_kind: BTreeMap<&'static str, usize>,
by_confidence: BTreeMap<&'static str, usize>,
roots: Vec<RootView<'a>>,
}
#[derive(Serialize)]
struct RootView<'a> {
path: &'a Path,
relative_path: String,
kinds: Vec<&'static str>,
confidence: &'static str,
category: Vec<&'static str>,
}
let report = ReportView {
root,
total: summary.total,
by_kind: summary
.by_kind
.iter()
.map(|(kind, count)| (kind.token(), *count))
.collect(),
by_confidence: summary
.by_confidence
.iter()
.map(|(level, count)| (level.token(), *count))
.collect(),
roots: roots
.iter()
.map(|entry| {
let mut cats: BTreeSet<&'static str> = BTreeSet::new();
for kind in &entry.kinds {
cats.insert(category_token(kind.category()));
}
RootView {
path: &entry.path,
relative_path: relative_display(root, &entry.path),
kinds: entry.kinds.iter().map(|kind| kind.token()).collect(),
confidence: entry.confidence.token(),
category: cats.into_iter().collect(),
}
})
.collect(),
};
serde_json::to_string_pretty(&report).context("failed to serialize discover report as JSON")
}
pub fn category_token(category: RootCategory) -> &'static str {
match category {
RootCategory::Vcs => "vcs",
RootCategory::Workspace => "workspace",
RootCategory::Package => "package",
RootCategory::Docs => "docs",
RootCategory::Data => "data",
}
}
pub fn relative_display(base: &Path, target: &Path) -> String {
match target.strip_prefix(base) {
Ok(rel) => {
let s = rel.display().to_string();
if s.is_empty() { ".".to_string() } else { s }
}
Err(_) => target.display().to_string(),
}
}
enum DirClass {
Root {
kinds: Vec<RootKind>,
confidence: Confidence,
},
Container,
Skip,
}
fn push_children(dir: &Path, depth: usize, stack: &mut Vec<(PathBuf, usize)>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
let mut children: Vec<PathBuf> = Vec::new();
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_symlink() || !file_type.is_dir() {
continue;
}
if should_skip_directory(&entry.file_name()) {
continue;
}
children.push(entry.path());
}
children.sort();
for path in children.into_iter().rev() {
stack.push((path, depth + 1));
}
}
fn classify_dir(dir: &Path) -> DirClass {
if !dir.is_dir() {
return DirClass::Skip;
}
if dir.file_name().map(should_skip_directory).unwrap_or(false) {
return DirClass::Skip;
}
let mut kinds: Vec<RootKind> = Vec::new();
let mut confidence = Confidence::Weak;
let record =
|kind: RootKind, level: Confidence, kinds: &mut Vec<RootKind>, conf: &mut Confidence| {
kinds.push(kind);
if level > *conf {
*conf = level;
}
};
if dir.join(".git").exists() {
record(
RootKind::GitRepo,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
if dir.join(".hg").is_dir() {
record(
RootKind::HgRepo,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
if dir.join(".svn").is_dir() {
record(
RootKind::SvnRepo,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
if dir.join(".fslckout").is_file() || dir.join("_FOSSIL_").is_file() {
record(
RootKind::FossilRepo,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
if dir.join(".bzr").is_dir() {
record(
RootKind::BzrRepo,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
let cargo_toml = dir.join("Cargo.toml");
if cargo_toml.is_file() {
let value = read_toml(&cargo_toml);
let has_workspace = value.as_ref().and_then(|v| v.get("workspace")).is_some();
let has_package = value.as_ref().and_then(|v| v.get("package")).is_some();
if has_workspace {
record(
RootKind::CargoWorkspace,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
if has_package {
record(
RootKind::CargoPackage,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
if !has_workspace && !has_package {
record(
RootKind::CargoPackage,
Confidence::Medium,
&mut kinds,
&mut confidence,
);
}
}
let package_json = dir.join("package.json");
if package_json.is_file() {
record(
RootKind::NpmPackage,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
if let Some(value) = read_json(&package_json) {
if value.get("workspaces").is_some() {
record(
RootKind::YarnWorkspace,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
}
}
if dir.join("pnpm-workspace.yaml").is_file() || dir.join("pnpm-workspace.yml").is_file() {
record(
RootKind::PnpmWorkspace,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
if dir.join("lerna.json").is_file() {
record(
RootKind::LernaWorkspace,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
if dir.join("turbo.json").is_file() {
record(
RootKind::TurboWorkspace,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
if dir.join("pyproject.toml").is_file() {
record(
RootKind::PythonProject,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
if dir.join("go.work").is_file() {
record(
RootKind::GoWorkspace,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
if dir.join("go.mod").is_file() {
record(
RootKind::GoModule,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
if dir.join("pom.xml").is_file() {
record(
RootKind::MavenProject,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
let gradle_settings =
dir.join("settings.gradle").is_file() || dir.join("settings.gradle.kts").is_file();
let gradle_build = dir.join("build.gradle").is_file() || dir.join("build.gradle.kts").is_file();
if gradle_settings {
record(
RootKind::GradleWorkspace,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
} else if gradle_build {
record(
RootKind::GradleProject,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
if dir.join("CMakeLists.txt").is_file() {
record(
RootKind::CMakeProject,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
if dir.join("Gemfile").is_file() {
record(
RootKind::RubyProject,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
if dir.join("composer.json").is_file() {
record(
RootKind::PhpProject,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
if dir.join("mix.exs").is_file() {
record(
RootKind::ElixirProject,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
if dir_has_extension(dir, &["csproj", "fsproj", "vbproj", "sln"]) {
record(
RootKind::DotnetProject,
Confidence::Strong,
&mut kinds,
&mut confidence,
);
}
if dir.join("book.toml").is_file() {
record(
RootKind::MdBook,
Confidence::Medium,
&mut kinds,
&mut confidence,
);
}
if dir.join("mkdocs.yml").is_file() || dir.join("mkdocs.yaml").is_file() {
record(
RootKind::MkDocs,
Confidence::Medium,
&mut kinds,
&mut confidence,
);
}
if dir.join("_config.yml").is_file() {
record(
RootKind::Jekyll,
Confidence::Medium,
&mut kinds,
&mut confidence,
);
}
if dir.join("conf.py").is_file() && dir.join("index.rst").is_file() {
record(
RootKind::Sphinx,
Confidence::Medium,
&mut kinds,
&mut confidence,
);
}
if file_with_any_extension(dir, "docusaurus.config", &["js", "ts", "mjs", "cjs"]) {
record(
RootKind::Docusaurus,
Confidence::Medium,
&mut kinds,
&mut confidence,
);
}
if dir.join("hugo.toml").is_file()
|| dir.join("hugo.yaml").is_file()
|| dir.join("hugo.json").is_file()
{
record(
RootKind::Hugo,
Confidence::Medium,
&mut kinds,
&mut confidence,
);
}
if file_with_any_extension(dir, "gatsby-config", &["js", "ts", "mjs", "cjs"]) {
record(
RootKind::Gatsby,
Confidence::Medium,
&mut kinds,
&mut confidence,
);
}
if file_with_any_extension(dir, "astro.config", &["js", "ts", "mjs", "cjs"]) {
record(
RootKind::Astro,
Confidence::Medium,
&mut kinds,
&mut confidence,
);
}
if dir.join("docfx.json").is_file() {
record(
RootKind::DocFx,
Confidence::Medium,
&mut kinds,
&mut confidence,
);
}
if dir.join("dvc.yaml").is_file() || dir.join(".dvc").is_dir() {
record(
RootKind::DvcDataset,
Confidence::Medium,
&mut kinds,
&mut confidence,
);
}
kinds.sort();
kinds.dedup();
if kinds.is_empty() {
DirClass::Container
} else {
DirClass::Root { kinds, confidence }
}
}
fn read_toml(path: &Path) -> Option<toml::Value> {
let content = fs::read_to_string(path).ok()?;
toml::from_str(&content).ok()
}
fn read_json(path: &Path) -> Option<serde_json::Value> {
serde_json::from_str(&fs::read_to_string(path).ok()?).ok()
}
fn dir_has_extension(dir: &Path, exts: &[&str]) -> bool {
let Ok(entries) = fs::read_dir(dir) else {
return false;
};
for entry in entries.flatten() {
let path = entry.path();
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
let lower = ext.to_ascii_lowercase();
if exts.iter().any(|allowed| *allowed == lower) {
return true;
}
}
}
false
}
fn file_with_any_extension(dir: &Path, stem: &str, exts: &[&str]) -> bool {
exts.iter()
.any(|ext| dir.join(format!("{stem}.{ext}")).is_file())
}
fn should_skip_directory(name: &OsStr) -> bool {
let Some(s) = name.to_str() else {
return false;
};
matches!(
s,
".git"
| ".hg"
| ".svn"
| ".bzr"
| "target"
| "node_modules"
| ".venv"
| "venv"
| "dist"
| "build"
| "out"
| "vendor"
| ".idea"
| ".vscode"
| ".cache"
| "__pycache__"
| ".gradle"
| ".tox"
| ".pytest_cache"
| ".mypy_cache"
| ".next"
| ".nuxt"
| ".turbo"
| ".parcel-cache"
| ".docusaurus"
| "_site"
| ".jekyll-cache"
| "Pods"
| "DerivedData"
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn write_file(dir: &Path, name: &str, content: &str) {
let path = dir.join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, content).unwrap();
}
fn make_dir(dir: &Path, name: &str) {
fs::create_dir_all(dir.join(name)).unwrap();
}
#[test]
fn empty_dir_yields_no_roots() {
let tmp = tempdir().unwrap();
let result = discover_roots(tmp.path(), &DiscoverOptions::default()).unwrap();
assert!(result.is_empty());
}
#[test]
fn single_cargo_package() {
let tmp = tempdir().unwrap();
write_file(
tmp.path(),
"Cargo.toml",
"[package]\nname = \"x\"\nversion = \"0.1.0\"\n",
);
let result = discover_roots(tmp.path(), &DiscoverOptions::default()).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].kinds.contains(&RootKind::CargoPackage));
assert_eq!(result[0].confidence, Confidence::Strong);
}
#[test]
fn workspace_default_does_not_expand() {
let tmp = tempdir().unwrap();
let root = tmp.path();
write_file(
root,
"Cargo.toml",
"[workspace]\nmembers = [\"a\", \"b\"]\n",
);
for member in ["a", "b"] {
write_file(
&root.join(member),
"Cargo.toml",
"[package]\nname = \"m\"\nversion = \"0.1.0\"\n",
);
}
let result = discover_roots(root, &DiscoverOptions::default()).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].kinds.contains(&RootKind::CargoWorkspace));
}
#[test]
fn workspace_expand_yields_members() {
let tmp = tempdir().unwrap();
let root = tmp.path();
write_file(
root,
"Cargo.toml",
"[workspace]\nmembers = [\"a\", \"b\"]\n",
);
for member in ["a", "b"] {
write_file(
&root.join(member),
"Cargo.toml",
"[package]\nname = \"m\"\nversion = \"0.1.0\"\n",
);
}
let opts = DiscoverOptions {
expand_workspaces: true,
..DiscoverOptions::default()
};
let result = discover_roots(root, &opts).unwrap();
assert_eq!(result.len(), 3);
}
#[test]
fn side_by_side_repos_are_separate_roots() {
let tmp = tempdir().unwrap();
let root = tmp.path();
for repo in ["repoA", "repoB"] {
let dir = root.join(repo);
fs::create_dir_all(&dir).unwrap();
fs::create_dir(dir.join(".git")).unwrap();
}
let result = discover_roots(root, &DiscoverOptions::default()).unwrap();
assert_eq!(result.len(), 2);
assert!(result.iter().all(|r| r.kinds.contains(&RootKind::GitRepo)));
}
#[test]
fn mdbook_default_passes_medium_filter() {
let tmp = tempdir().unwrap();
write_file(tmp.path(), "book.toml", "[book]\ntitle = \"x\"\n");
let result = discover_roots(tmp.path(), &DiscoverOptions::default()).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].kinds.contains(&RootKind::MdBook));
assert_eq!(result[0].confidence, Confidence::Medium);
}
#[test]
fn min_confidence_strong_filters_mdbook() {
let tmp = tempdir().unwrap();
write_file(tmp.path(), "book.toml", "[book]\ntitle = \"x\"\n");
let opts = DiscoverOptions {
min_confidence: Confidence::Strong,
..DiscoverOptions::default()
};
let result = discover_roots(tmp.path(), &opts).unwrap();
assert!(result.is_empty());
}
#[test]
fn nested_vcs_default_skipped() {
let tmp = tempdir().unwrap();
let outer = tmp.path();
fs::create_dir(outer.join(".git")).unwrap();
make_dir(outer, "third_party/dep");
fs::create_dir(outer.join("third_party/dep/.git")).unwrap();
let result = discover_roots(outer, &DiscoverOptions::default()).unwrap();
assert_eq!(result.len(), 1);
let opts = DiscoverOptions {
nested_vcs: true,
..DiscoverOptions::default()
};
let result = discover_roots(outer, &opts).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn include_kind_filter() {
let tmp = tempdir().unwrap();
let root = tmp.path();
let a = root.join("a");
let b = root.join("b");
fs::create_dir_all(&a).unwrap();
fs::create_dir_all(&b).unwrap();
write_file(
&a,
"Cargo.toml",
"[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
);
write_file(&b, "package.json", "{\"name\":\"b\"}\n");
let mut filter = BTreeSet::new();
filter.insert(RootKind::NpmPackage);
let opts = DiscoverOptions {
include_kinds: Some(filter),
..DiscoverOptions::default()
};
let result = discover_roots(root, &opts).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].kinds.contains(&RootKind::NpmPackage));
}
#[test]
fn skips_node_modules_and_target() {
let tmp = tempdir().unwrap();
let root = tmp.path();
let buried = root.join("node_modules").join("inner");
fs::create_dir_all(&buried).unwrap();
write_file(&buried, "package.json", "{\"name\":\"inner\"}\n");
let result = discover_roots(root, &DiscoverOptions::default()).unwrap();
assert!(result.is_empty());
}
#[test]
fn from_token_round_trip() {
for kind in [
RootKind::GitRepo,
RootKind::CargoPackage,
RootKind::MdBook,
RootKind::DvcDataset,
] {
assert_eq!(RootKind::from_token(kind.token()), Some(kind));
}
assert_eq!(RootKind::from_token("git"), Some(RootKind::GitRepo));
assert_eq!(RootKind::from_token("cargo"), Some(RootKind::CargoPackage));
assert_eq!(RootKind::from_token("nope"), None);
}
}