use std::collections::BTreeMap; use std::fmt; use std::fs; use std::path::{Path, PathBuf}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProjectInfo { pub root: PathBuf, pub git_dir: PathBuf, pub head: ProjectHead, } impl ProjectInfo { pub fn branch_label(&self) -> String { self.head.to_string() } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProjectHead { Branch(String), Detached(String), Unknown, } impl fmt::Display for ProjectHead { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Branch(branch) => formatter.write_str(branch), Self::Detached(commit) => write!(formatter, "detached at {commit}"), Self::Unknown => formatter.write_str("unknown"), } } } pub fn detect_project( start: &Path, root_override: Option<&Path>, ) -> Result, ProjectError> { let search_start = resolve_start(start, root_override); let Some((root, git_dir)) = find_git_root(&search_start)? else { return Ok(None); }; let head = read_project_head(&git_dir)?; Ok(Some(ProjectInfo { root, git_dir, head, })) } pub fn render_project_status(project: Option<&ProjectInfo>) -> String { let Some(project) = project else { return "Project\nstatus: not detected".into(); }; format!( "Project\nroot: {}\nbranch: {}\ngit_dir: {}", project.root.display(), project.branch_label(), project.git_dir.display() ) } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProjectSummary { pub root: PathBuf, pub branch: String, pub major_directories: Vec, pub languages: Vec, pub entry_points: Vec, pub build_files: Vec, pub files_seen: usize, pub truncated: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct LanguageSummary { pub language: String, pub files: usize, } pub fn summarize_project( start: &Path, root_override: Option<&Path>, ) -> Result { let project = detect_project(start, root_override)?.ok_or(ProjectError::NotDetected)?; Ok(summarize_project_root(&project)) } pub fn render_project_summary(summary: &ProjectSummary) -> String { let mut rendered = String::new(); rendered.push_str(&format!( "Project Summary\nroot: {}\n", summary.root.display() )); rendered.push_str(&format!("branch: {}\n", summary.branch)); rendered.push_str(&format!("files_seen: {}\n", summary.files_seen)); rendered.push_str(&format!("truncated: {}\n\n", summary.truncated)); rendered.push_str("major_directories:\n"); render_lines(&mut rendered, &summary.major_directories); rendered.push_str("\nlanguages:\n"); if summary.languages.is_empty() { rendered.push_str("- none\n"); } else { for language in &summary.languages { rendered.push_str(&format!( "- {}: {} files\n", language.language, language.files )); } } rendered.push_str("\nentry_points:\n"); render_lines(&mut rendered, &summary.entry_points); rendered.push_str("\nbuild_files:\n"); render_lines(&mut rendered, &summary.build_files); rendered.trim_end().to_string() } fn summarize_project_root(project: &ProjectInfo) -> ProjectSummary { const MAX_FILES: usize = 1_000; let major_directories = major_directories(&project.root); let mut languages = BTreeMap::::new(); let mut entry_points = Vec::new(); let mut build_files = Vec::new(); let mut files_seen = 0; let mut truncated = false; let mut stack = vec![project.root.clone()]; while let Some(path) = stack.pop() { let Ok(entries) = fs::read_dir(&path) else { continue; }; let mut entries = entries.filter_map(Result::ok).collect::>(); entries.sort_by_key(|entry| entry.file_name()); for entry in entries { let entry_path = entry.path(); let name = entry.file_name().to_string_lossy().to_string(); if is_noisy_project_path(&name) { continue; } let Ok(file_type) = entry.file_type() else { continue; }; if file_type.is_dir() { stack.push(entry_path); continue; } if !file_type.is_file() { continue; } files_seen += 1; if files_seen > MAX_FILES { truncated = true; break; } let relative = relative_display(&project.root, &entry_path); if let Some(language) = language_for_path(&entry_path) { *languages.entry(language.into()).or_insert(0) += 1; } if is_likely_entry_point(&relative) { entry_points.push(relative.clone()); } if is_build_file(&relative) { build_files.push(relative); } } if truncated { break; } } let mut languages = languages .into_iter() .map(|(language, files)| LanguageSummary { language, files }) .collect::>(); languages.sort_by(|left, right| { right .files .cmp(&left.files) .then(left.language.cmp(&right.language)) }); entry_points.sort(); entry_points.dedup(); build_files.sort(); build_files.dedup(); ProjectSummary { root: project.root.clone(), branch: project.branch_label(), major_directories, languages, entry_points, build_files, files_seen: files_seen.min(MAX_FILES), truncated, } } fn major_directories(root: &Path) -> Vec { let Ok(entries) = fs::read_dir(root) else { return Vec::new(); }; let mut directories = entries .filter_map(Result::ok) .filter_map(|entry| { let name = entry.file_name().to_string_lossy().to_string(); if is_noisy_project_path(&name) || !entry.file_type().ok()?.is_dir() { None } else { Some(format!("{name}/")) } }) .collect::>(); directories.sort(); directories } fn render_lines(rendered: &mut String, lines: &[String]) { if lines.is_empty() { rendered.push_str("- none\n"); } else { for line in lines { rendered.push_str(&format!("- {line}\n")); } } } fn relative_display(root: &Path, path: &Path) -> String { path.strip_prefix(root) .unwrap_or(path) .to_string_lossy() .replace('\\', "/") } fn is_noisy_project_path(name: &str) -> bool { matches!( name, ".git" | "target" | "node_modules" | ".venv" | "venv" | "dist" | "build" | ".next" | ".cache" ) } fn language_for_path(path: &Path) -> Option<&'static str> { match path.extension()?.to_string_lossy().as_ref() { "rs" => Some("Rust"), "toml" => Some("TOML"), "md" => Some("Markdown"), "js" | "jsx" => Some("JavaScript"), "ts" | "tsx" => Some("TypeScript"), "py" => Some("Python"), "ps1" => Some("PowerShell"), "sh" | "bash" | "zsh" => Some("Shell"), "json" => Some("JSON"), "yaml" | "yml" => Some("YAML"), _ => None, } } fn is_likely_entry_point(relative: &str) -> bool { matches!( relative, "src/main.rs" | "src/lib.rs" | "main.py" | "app.py" | "index.js" | "index.ts" | "src/index.js" | "src/index.ts" ) } fn is_build_file(relative: &str) -> bool { matches!( relative, "Cargo.toml" | "package.json" | "pyproject.toml" | "Makefile" | "justfile" | "go.mod" | "pom.xml" | "build.gradle" ) } fn resolve_start(start: &Path, root_override: Option<&Path>) -> PathBuf { match root_override { Some(root) if root.is_absolute() => root.to_path_buf(), Some(root) => start.join(root), None => start.to_path_buf(), } } fn find_git_root(start: &Path) -> Result, ProjectError> { let mut current = if start.is_file() { start.parent().unwrap_or(start).to_path_buf() } else { start.to_path_buf() }; loop { let git_marker = current.join(".git"); if git_marker.exists() { let git_dir = resolve_git_dir(&git_marker)?; if git_dir.join("HEAD").exists() { return Ok(Some((current, git_dir))); } } if !current.pop() { return Ok(None); } } } fn resolve_git_dir(git_marker: &Path) -> Result { if git_marker.is_dir() { return Ok(git_marker.to_path_buf()); } let contents = fs::read_to_string(git_marker).map_err(|error| ProjectError::Read { path: git_marker.to_path_buf(), error: error.to_string(), })?; let git_dir = contents .trim() .strip_prefix("gitdir:") .map(str::trim) .ok_or_else(|| ProjectError::InvalidGitFile(git_marker.to_path_buf()))?; let git_dir = PathBuf::from(git_dir); if git_dir.is_absolute() { Ok(git_dir) } else { Ok(git_marker .parent() .unwrap_or_else(|| Path::new(".")) .join(git_dir)) } } fn read_project_head(git_dir: &Path) -> Result { let head_path = git_dir.join("HEAD"); let contents = fs::read_to_string(&head_path).map_err(|error| ProjectError::Read { path: head_path, error: error.to_string(), })?; let head = contents.trim(); if head.is_empty() { return Ok(ProjectHead::Unknown); } if let Some(reference) = head.strip_prefix("ref:") { let branch = reference .trim() .strip_prefix("refs/heads/") .unwrap_or_else(|| reference.trim()); if branch.is_empty() { Ok(ProjectHead::Unknown) } else { Ok(ProjectHead::Branch(branch.to_string())) } } else { Ok(ProjectHead::Detached(short_commit(head))) } } fn short_commit(value: &str) -> String { value.chars().take(12).collect() } #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum ProjectError { #[error("failed to read project metadata at {path}: {error}")] Read { path: PathBuf, error: String }, #[error("invalid git file at {0}")] InvalidGitFile(PathBuf), #[error("project not detected")] NotDetected, } #[cfg(test)] mod tests { use std::fs; use super::*; #[test] fn detects_nested_git_repository_root_and_branch() { let tempdir = tempfile::tempdir().expect("tempdir"); let outer = tempdir.path().join("outer"); let nested = outer.join("nested"); let source = nested.join("src"); write_head(&outer, "main"); write_head(&nested, "feature/branch"); fs::create_dir_all(&source).expect("source dir"); let project = detect_project(&source, None) .expect("project detection") .expect("project"); assert_eq!(project.root, nested); assert_eq!( project.head, ProjectHead::Branch("feature/branch".to_string()) ); } #[test] fn override_root_selects_requested_repository() { let tempdir = tempfile::tempdir().expect("tempdir"); let outer = tempdir.path().join("outer"); let nested = outer.join("nested"); let source = nested.join("src"); write_head(&outer, "main"); write_head(&nested, "feature"); fs::create_dir_all(&source).expect("source dir"); let project = detect_project(&source, Some(&outer)) .expect("project detection") .expect("project"); assert_eq!(project.root, outer); assert_eq!(project.head, ProjectHead::Branch("main".to_string())); } #[test] fn detached_head_is_rendered_gracefully() { let tempdir = tempfile::tempdir().expect("tempdir"); let repo = tempdir.path().join("repo"); fs::create_dir_all(repo.join(".git")).expect("git dir"); fs::write( repo.join(".git").join("HEAD"), "d34db33fd34db33fd34db33fd34db33fd34db33f\n", ) .expect("head"); let project = detect_project(&repo, None) .expect("project detection") .expect("project"); assert_eq!( project.head, ProjectHead::Detached("d34db33fd34d".to_string()) ); assert!(render_project_status(Some(&project)).contains("detached at d34db33fd34d")); } #[test] fn returns_none_without_git_repository() { let tempdir = tempfile::tempdir().expect("tempdir"); let project = detect_project(tempdir.path(), None).expect("project detection"); assert_eq!(project, None); } #[test] fn summarizes_project_without_full_indexing() { let tempdir = tempfile::tempdir().expect("tempdir"); let repo = tempdir.path().join("repo"); write_head(&repo, "main"); fs::create_dir_all(repo.join("src")).expect("src dir"); fs::create_dir_all(repo.join("target")).expect("target dir"); fs::write(repo.join("Cargo.toml"), "[package]\nname = \"demo\"\n").expect("cargo"); fs::write(repo.join("src").join("main.rs"), "fn main() {}\n").expect("main"); fs::write(repo.join("README.md"), "# demo\n").expect("readme"); fs::write(repo.join("target").join("ignored.rs"), "ignored").expect("ignored"); let summary = summarize_project(&repo, None).expect("summary"); let rendered = render_project_summary(&summary); assert_eq!(summary.branch, "main"); assert!(summary.major_directories.contains(&"src/".to_string())); assert!(!summary.major_directories.contains(&"target/".to_string())); assert!(summary.entry_points.contains(&"src/main.rs".to_string())); assert!(summary.build_files.contains(&"Cargo.toml".to_string())); assert!(rendered.contains("languages:")); assert!(rendered.contains("Rust")); } fn write_head(root: &Path, branch: &str) { let git_dir = root.join(".git"); fs::create_dir_all(&git_dir).expect("git dir"); fs::write(git_dir.join("HEAD"), format!("ref: refs/heads/{branch}\n")).expect("head"); } }