use std::collections::HashMap;
use std::path::{Path, PathBuf};
use once_cell::sync::Lazy;
use rustc_hash::FxHashSet;
use crate::lang::LanguageRegistry;
#[must_use]
pub fn normalize_path(path: &Path) -> PathBuf {
if let Ok(canonical) = path.canonicalize() {
return canonical;
}
let mut result = PathBuf::new();
let is_absolute = path.is_absolute();
for component in path.components() {
match component {
std::path::Component::CurDir => {
}
std::path::Component::ParentDir => {
result.pop();
}
_ => {
result.push(component);
}
}
}
if is_absolute && !result.is_absolute() {
if let Ok(cwd) = std::env::current_dir() {
return cwd.join(result);
}
}
result
}
const PROJECT_MARKERS: &[&str] = &[
".git",
".hg",
".svn",
"pyproject.toml",
"setup.py",
"setup.cfg",
"requirements.txt",
"Cargo.toml",
"package.json",
"tsconfig.json",
"go.mod",
"go.work",
"pom.xml",
"build.gradle",
"build.gradle.kts",
"settings.gradle",
"CMakeLists.txt",
"Makefile",
"meson.build",
".brrrignore",
".editorconfig",
];
#[must_use]
pub fn get_project_root(path: &Path) -> Option<PathBuf> {
let start = normalize_path(path);
let mut current = if start.is_file() {
start.parent()?.to_path_buf()
} else {
start
};
loop {
for marker in PROJECT_MARKERS {
if current.join(marker).exists() {
return Some(current);
}
}
match current.parent() {
Some(parent) => {
if parent == current {
break;
}
current = parent.to_path_buf();
}
None => break,
}
}
None
}
#[must_use]
#[allow(dead_code)]
pub fn detect_language(path: &Path) -> Option<&'static str> {
let registry = LanguageRegistry::global();
registry.detect_language(path).map(|lang| lang.name())
}
const EXTENSION_TO_LANG: &[(&str, &str)] = &[
("py", "python"),
("pyi", "python"),
("rs", "rust"),
("ts", "typescript"),
("tsx", "typescript"),
("js", "javascript"),
("jsx", "javascript"),
("mjs", "javascript"),
("cjs", "javascript"),
("go", "go"),
("java", "java"),
("c", "c"),
("h", "c"),
("cpp", "cpp"),
("cc", "cpp"),
("cxx", "cpp"),
("hpp", "cpp"),
("hh", "cpp"),
("rb", "ruby"),
("php", "php"),
("kt", "kotlin"),
("kts", "kotlin"),
("swift", "swift"),
("cs", "csharp"),
("scala", "scala"),
("sc", "scala"),
("lua", "lua"),
("ex", "elixir"),
("exs", "elixir"),
];
pub fn require_exists(path: &Path) -> Result<(), PathValidationError> {
if !path.exists() {
return Err(PathValidationError::NotFound(path.display().to_string()));
}
Ok(())
}
pub fn require_file(path: &Path) -> Result<(), PathValidationError> {
if !path.exists() {
return Err(PathValidationError::FileNotFound(path.display().to_string()));
}
if !path.is_file() {
return Err(PathValidationError::ExpectedFile(path.display().to_string()));
}
Ok(())
}
pub fn require_directory(path: &Path) -> Result<(), PathValidationError> {
if !path.exists() {
return Err(PathValidationError::DirectoryNotFound(path.display().to_string()));
}
if !path.is_dir() {
return Err(PathValidationError::ExpectedDirectory(path.display().to_string()));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathValidationError {
NotFound(String),
FileNotFound(String),
DirectoryNotFound(String),
ExpectedFile(String),
ExpectedDirectory(String),
}
impl std::fmt::Display for PathValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound(path) => write!(f, "Path not found: {}", path),
Self::FileNotFound(path) => write!(f, "File not found: {}", path),
Self::DirectoryNotFound(path) => write!(f, "Directory not found: {}", path),
Self::ExpectedFile(path) => write!(f, "Expected file but found directory: {}", path),
Self::ExpectedDirectory(path) => write!(f, "Expected directory but found file: {}", path),
}
}
}
impl std::error::Error for PathValidationError {}
static SKIP_DIRECTORIES: Lazy<FxHashSet<&'static str>> = Lazy::new(|| {
[
"node_modules",
"__pycache__",
".pycache",
"vendor",
"dist",
"build",
"target",
".git",
".hg",
".svn",
"venv",
".venv",
"env",
".env",
]
.into_iter()
.collect()
});
#[must_use]
pub fn detect_project_language(path: &Path) -> String {
let project = normalize_path(path);
let cache_file = project.join(".brrr").join("cache").join("call_graph.json");
if cache_file.exists() {
if let Ok(content) = std::fs::read_to_string(&cache_file) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(languages) = json.get("languages").and_then(|v| v.as_array()) {
if let Some(first) = languages.first().and_then(|v| v.as_str()) {
return first.to_string();
}
}
if let Some(lang) = json.get("language").and_then(|v| v.as_str()) {
return lang.to_string();
}
}
}
}
let ext_to_lang: HashMap<&str, &str> = EXTENSION_TO_LANG.iter().copied().collect();
let mut counts: HashMap<&str, usize> = HashMap::new();
let scan_dir = if project.join("src").is_dir() {
project.join("src")
} else if project.join("lib").is_dir() {
project.join("lib")
} else {
project.clone()
};
let walker = walkdir::WalkDir::new(&scan_dir)
.max_depth(10)
.into_iter()
.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
if name.starts_with('.') && e.file_type().is_dir() {
return false;
}
if e.file_type().is_dir() && SKIP_DIRECTORIES.contains(name.as_ref()) {
return false;
}
true
});
for entry in walker.filter_map(std::result::Result::ok) {
if !entry.file_type().is_file() {
continue;
}
if let Some(ext) = entry.path().extension().and_then(|e| e.to_str()) {
let ext_lower = ext.to_lowercase();
if let Some(&lang) = ext_to_lang.get(ext_lower.as_str()) {
*counts.entry(lang).or_insert(0) += 1;
}
}
}
if let Some((&lang, _)) = counts.iter().max_by_key(|(_, &count)| count) {
return lang.to_string();
}
"python".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_normalize_path_relative() {
let path = Path::new("./src/../src/main.rs");
let normalized = normalize_path(path);
assert!(!normalized.to_string_lossy().contains("./"));
assert!(!normalized.to_string_lossy().contains(".."));
}
#[test]
fn test_get_project_root_with_git() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().join("project");
let src_dir = project_dir.join("src");
fs::create_dir_all(&src_dir).unwrap();
fs::create_dir(project_dir.join(".git")).unwrap();
let file_path = src_dir.join("main.rs");
fs::write(&file_path, "fn main() {}").unwrap();
let root = get_project_root(&file_path);
assert!(root.is_some());
assert_eq!(root.unwrap().file_name().unwrap(), "project");
}
#[test]
fn test_get_project_root_with_cargo() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().join("rust_project");
fs::create_dir_all(&project_dir).unwrap();
fs::write(project_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
let root = get_project_root(&project_dir);
assert!(root.is_some());
assert_eq!(root.unwrap().file_name().unwrap(), "rust_project");
}
#[test]
fn test_get_project_root_not_found() {
let temp_dir = TempDir::new().unwrap();
let orphan_dir = temp_dir.path().join("no_markers");
fs::create_dir_all(&orphan_dir).unwrap();
let file_path = orphan_dir.join("orphan.rs");
fs::write(&file_path, "fn main() {}").unwrap();
let root = get_project_root(temp_dir.path());
assert!(root.is_none() || root.as_ref().map(|p| p != temp_dir.path()).unwrap_or(true));
}
#[test]
fn test_detect_language() {
assert_eq!(detect_language(Path::new("main.py")), Some("python"));
assert_eq!(detect_language(Path::new("app.ts")), Some("typescript"));
assert_eq!(detect_language(Path::new("lib.rs")), Some("rust"));
assert_eq!(detect_language(Path::new("main.go")), Some("go"));
assert_eq!(detect_language(Path::new("Main.java")), Some("java"));
assert_eq!(detect_language(Path::new("util.c")), Some("c"));
assert_eq!(detect_language(Path::new("readme.md")), None);
assert_eq!(detect_language(Path::new("config.json")), None);
}
#[test]
fn test_detect_project_language_from_files() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&project_dir).unwrap();
fs::write(project_dir.join("main.py"), "print('hello')").unwrap();
fs::write(project_dir.join("util.py"), "def foo(): pass").unwrap();
fs::write(project_dir.join("helper.py"), "class A: pass").unwrap();
fs::write(project_dir.join("lib.rs"), "fn main() {}").unwrap();
let detected = detect_project_language(&project_dir);
assert_eq!(detected, "python");
}
#[test]
fn test_detect_project_language_prefers_src_dir() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().join("project");
let src_dir = project_dir.join("src");
fs::create_dir_all(&src_dir).unwrap();
fs::write(project_dir.join("setup.py"), "from setuptools import setup").unwrap();
fs::write(project_dir.join("config.py"), "DEBUG = True").unwrap();
fs::write(src_dir.join("main.rs"), "fn main() {}").unwrap();
fs::write(src_dir.join("lib.rs"), "pub mod utils;").unwrap();
fs::write(src_dir.join("utils.rs"), "pub fn foo() {}").unwrap();
let detected = detect_project_language(&project_dir);
assert_eq!(detected, "rust");
}
#[test]
fn test_detect_project_language_from_cache() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().join("project");
let cache_dir = project_dir.join(".brrr").join("cache");
fs::create_dir_all(&cache_dir).unwrap();
let cache_content = r#"{"languages": ["go", "python"], "edges": []}"#;
fs::write(cache_dir.join("call_graph.json"), cache_content).unwrap();
fs::write(project_dir.join("main.py"), "print('hello')").unwrap();
let detected = detect_project_language(&project_dir);
assert_eq!(detected, "go");
}
#[test]
fn test_detect_project_language_skips_vendor_dirs() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&project_dir).unwrap();
let node_modules = project_dir.join("node_modules").join("some_package");
fs::create_dir_all(&node_modules).unwrap();
for i in 0..10 {
fs::write(node_modules.join(format!("file{i}.js")), "module.exports = {}").unwrap();
}
fs::write(project_dir.join("main.py"), "print('hello')").unwrap();
let detected = detect_project_language(&project_dir);
assert_eq!(detected, "python");
}
#[test]
fn test_detect_project_language_default_fallback() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().join("empty_project");
fs::create_dir_all(&project_dir).unwrap();
fs::write(project_dir.join("README.md"), "# Empty").unwrap();
fs::write(project_dir.join("config.json"), "{}").unwrap();
let detected = detect_project_language(&project_dir);
assert_eq!(detected, "python");
}
#[test]
fn test_require_exists_with_existing_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("existing.txt");
fs::write(&file_path, "content").unwrap();
assert!(require_exists(&file_path).is_ok());
}
#[test]
fn test_require_exists_with_existing_directory() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path().join("existing_dir");
fs::create_dir_all(&dir_path).unwrap();
assert!(require_exists(&dir_path).is_ok());
}
#[test]
fn test_require_exists_with_nonexistent_path() {
let path = Path::new("/nonexistent/path/to/file.txt");
let result = require_exists(path);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, PathValidationError::NotFound(_)));
assert!(err.to_string().contains("Path not found"));
}
#[test]
fn test_require_file_with_existing_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test_file.py");
fs::write(&file_path, "print('hello')").unwrap();
assert!(require_file(&file_path).is_ok());
}
#[test]
fn test_require_file_with_nonexistent_file() {
let path = Path::new("/nonexistent/file.py");
let result = require_file(path);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, PathValidationError::FileNotFound(_)));
assert!(err.to_string().contains("File not found"));
}
#[test]
fn test_require_file_with_directory() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path().join("a_directory");
fs::create_dir_all(&dir_path).unwrap();
let result = require_file(&dir_path);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, PathValidationError::ExpectedFile(_)));
assert!(err.to_string().contains("Expected file but found directory"));
}
#[test]
fn test_require_directory_with_existing_directory() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path().join("test_dir");
fs::create_dir_all(&dir_path).unwrap();
assert!(require_directory(&dir_path).is_ok());
}
#[test]
fn test_require_directory_with_nonexistent_directory() {
let path = Path::new("/nonexistent/directory");
let result = require_directory(path);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, PathValidationError::DirectoryNotFound(_)));
assert!(err.to_string().contains("Directory not found"));
}
#[test]
fn test_require_directory_with_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("a_file.txt");
fs::write(&file_path, "content").unwrap();
let result = require_directory(&file_path);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, PathValidationError::ExpectedDirectory(_)));
assert!(err.to_string().contains("Expected directory but found file"));
}
#[test]
fn test_path_validation_error_display() {
assert_eq!(
PathValidationError::NotFound("/some/path".to_string()).to_string(),
"Path not found: /some/path"
);
assert_eq!(
PathValidationError::FileNotFound("/some/file.txt".to_string()).to_string(),
"File not found: /some/file.txt"
);
assert_eq!(
PathValidationError::DirectoryNotFound("/some/dir".to_string()).to_string(),
"Directory not found: /some/dir"
);
assert_eq!(
PathValidationError::ExpectedFile("/some/dir".to_string()).to_string(),
"Expected file but found directory: /some/dir"
);
assert_eq!(
PathValidationError::ExpectedDirectory("/some/file.txt".to_string()).to_string(),
"Expected directory but found file: /some/file.txt"
);
}
}