use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
use rayon::prelude::*;
use crate::error::Result;
use crate::git::GitMeta;
#[derive(Debug, Clone)]
pub struct FileEntry {
pub relative: String,
pub absolute: PathBuf,
pub size: u64,
}
#[derive(Debug, Clone, Default)]
pub struct Inventory {
files: BTreeMap<String, FileEntry>,
lower_index: HashMap<String, String>,
dirs: BTreeSet<String>,
}
impl Inventory {
pub fn scan(root: &Path) -> Result<Self> {
let mut builder = WalkBuilder::new(root);
builder.hidden(false);
builder.git_ignore(true);
builder.git_global(true);
builder.git_exclude(true);
builder.parents(true);
let mut files = BTreeMap::new();
let mut lower_index = HashMap::new();
let mut dirs = BTreeSet::new();
for entry in builder.build().filter_map(|e| e.ok()) {
let path = entry.path();
if path == root {
continue;
}
let Ok(rel) = path.strip_prefix(root) else {
continue;
};
let relative = normalize_rel(rel);
if relative.is_empty() {
continue;
}
if relative == ".git" || relative.starts_with(".git/") {
continue;
}
if entry.file_type().is_some_and(|t| t.is_dir()) {
dirs.insert(relative);
continue;
}
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
let absolute = path.to_path_buf();
lower_index.insert(relative.to_ascii_lowercase(), relative.clone());
if let Some(parent) = Path::new(&relative).parent() {
let p = normalize_rel(parent);
if !p.is_empty() {
dirs.insert(p);
}
}
files.insert(
relative.clone(),
FileEntry {
relative,
absolute,
size,
},
);
}
Ok(Self {
files,
lower_index,
dirs,
})
}
pub fn len(&self) -> usize {
self.files.len()
}
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &FileEntry> {
self.files.values()
}
pub fn get(&self, relative: &str) -> Option<&FileEntry> {
let key = relative.replace('\\', "/");
self.files.get(&key).or_else(|| {
self.lower_index
.get(&key.to_ascii_lowercase())
.and_then(|canon| self.files.get(canon))
})
}
pub fn has_file(&self, relative: &str) -> bool {
self.get(relative).is_some()
}
pub fn has_dir(&self, relative: &str) -> bool {
let key = relative.trim_matches('/').replace('\\', "/");
self.dirs.contains(&key) || self.files.keys().any(|f| f.starts_with(&format!("{key}/")))
}
pub fn find_matching<F>(&self, mut pred: F) -> Vec<&FileEntry>
where
F: FnMut(&str) -> bool,
{
self.files.values().filter(|e| pred(&e.relative)).collect()
}
pub fn find_by_basenames(&self, names: &[&str]) -> Vec<&FileEntry> {
let lower: Vec<String> = names.iter().map(|n| n.to_ascii_lowercase()).collect();
self.files
.values()
.filter(|e| {
Path::new(&e.relative)
.file_name()
.and_then(|n| n.to_str())
.map(|n| lower.iter().any(|l| n.eq_ignore_ascii_case(l)))
.unwrap_or(false)
})
.collect()
}
pub fn find_path_contains(&self, needle: &str) -> Vec<&FileEntry> {
let needle = needle.to_ascii_lowercase();
self.files
.values()
.filter(|e| e.relative.to_ascii_lowercase().contains(&needle))
.collect()
}
}
#[derive(Debug)]
pub struct RepoContext {
pub root: PathBuf,
pub inventory: Inventory,
pub git: GitMeta,
content_cache: std::sync::Mutex<HashMap<String, Option<String>>>,
}
impl RepoContext {
pub fn new(root: PathBuf, inventory: Inventory, git: GitMeta) -> Self {
Self {
root,
inventory,
git,
content_cache: std::sync::Mutex::new(HashMap::new()),
}
}
pub fn has_file(&self, relative: &str) -> bool {
self.inventory.has_file(relative)
}
pub fn has_dir(&self, relative: &str) -> bool {
self.inventory.has_dir(relative)
}
pub fn read_text(&self, relative: &str) -> Option<String> {
let entry = self.inventory.get(relative)?;
let key = entry.relative.clone();
{
let cache = self.content_cache.lock().ok()?;
if let Some(cached) = cache.get(&key) {
return cached.clone();
}
}
let content = if entry.size > 1_048_576 {
None
} else {
std::fs::read_to_string(&entry.absolute).ok()
};
if let Ok(mut cache) = self.content_cache.lock() {
cache.insert(key, content.clone());
}
content
}
pub fn first_existing<'a>(&self, candidates: &[&'a str]) -> Option<&'a str> {
candidates.iter().copied().find(|c| self.has_file(c))
}
pub fn any_path_matches(&self, needles: &[&str]) -> Vec<String> {
let mut hits = Vec::new();
for needle in needles {
let n = needle.to_ascii_lowercase();
for f in self.inventory.iter() {
let rel = f.relative.to_ascii_lowercase();
if rel == n || rel.ends_with(&format!("/{n}")) || rel.contains(&n) {
hits.push(f.relative.clone());
}
}
}
hits.sort();
hits.dedup();
hits
}
pub fn existing_of(&self, candidates: &[&str]) -> Vec<String> {
candidates
.par_iter()
.filter(|c| self.has_file(c))
.map(|c| (*c).to_string())
.collect()
}
pub fn detect_signals(&self) -> ToolSignals {
ToolSignals::detect(&self.inventory)
}
}
#[derive(Debug, Clone, Default)]
pub struct ToolSignals {
pub has_cargo: bool,
pub has_package_json: bool,
pub has_pyproject: bool,
pub has_requirements_txt: bool,
pub has_go_mod: bool,
pub has_gemfile: bool,
pub has_maven: bool,
pub has_gradle: bool,
pub has_dotnet: bool,
pub ci_workflow_paths: Vec<String>,
pub pre_commit_config: bool,
pub editorconfig: bool,
pub gitignore: bool,
}
impl ToolSignals {
fn detect(inv: &Inventory) -> Self {
let ci = inv
.find_matching(|p| {
let l = p.to_ascii_lowercase();
l.starts_with(".github/workflows/") && (l.ends_with(".yml") || l.ends_with(".yaml"))
})
.into_iter()
.map(|e| e.relative.clone())
.collect();
Self {
has_cargo: inv.has_file("Cargo.toml"),
has_package_json: inv.has_file("package.json"),
has_pyproject: inv.has_file("pyproject.toml") || inv.has_file("setup.py"),
has_requirements_txt: inv.has_file("requirements.txt"),
has_go_mod: inv.has_file("go.mod"),
has_gemfile: inv.has_file("Gemfile"),
has_maven: inv.has_file("pom.xml"),
has_gradle: inv.has_file("build.gradle")
|| inv.has_file("build.gradle.kts")
|| inv.has_file("settings.gradle")
|| inv.has_file("settings.gradle.kts"),
has_dotnet: !inv
.find_matching(|p| p.ends_with(".csproj") || p.ends_with(".sln"))
.is_empty(),
ci_workflow_paths: ci,
pre_commit_config: inv.has_file(".pre-commit-config.yaml")
|| inv.has_file(".pre-commit-config.yml"),
editorconfig: inv.has_file(".editorconfig"),
gitignore: inv.has_file(".gitignore"),
}
}
}
fn normalize_rel(path: &Path) -> String {
path.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn inventory_finds_readme() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("README.md"), "x").unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src/main.rs"), "fn main(){}").unwrap();
let inv = Inventory::scan(dir.path()).unwrap();
assert!(inv.has_file("README.md"));
assert!(inv.has_file("readme.md"));
assert!(inv.has_dir("src"));
assert_eq!(inv.len(), 2);
}
}