use std::env;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use tracing::debug;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProjectType {
Rust,
Node,
Python,
Go,
CMake,
Git,
Unknown,
}
impl ProjectType {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Rust => "Rust",
Self::Node => "Node.js",
Self::Python => "Python",
Self::Go => "Go",
Self::CMake => "CMake",
Self::Git => "Git",
Self::Unknown => "Unknown",
}
}
}
#[derive(Debug, Clone)]
pub struct DirectoryContext {
pub project_type: Option<ProjectType>,
pub marker_files: Vec<String>,
pub cwd: PathBuf,
}
const MAX_MARKERS_IN_PROMPT: usize = 20;
impl DirectoryContext {
#[must_use]
pub fn format_for_prompt(&self) -> String {
let mut parts = Vec::new();
parts.push(format!("Working Directory: {}", self.cwd.display()));
let project_str = self
.project_type
.as_ref()
.map_or("Unknown", ProjectType::as_str);
parts.push(format!("Project Type: {project_str}"));
if !self.marker_files.is_empty() {
let markers: Vec<&str> = self
.marker_files
.iter()
.take(MAX_MARKERS_IN_PROMPT)
.map(String::as_str)
.collect();
let markers_str = markers.join(", ");
if self.marker_files.len() > MAX_MARKERS_IN_PROMPT {
parts.push(format!(
"Marker Files: {} (and {} more)",
markers_str,
self.marker_files.len() - MAX_MARKERS_IN_PROMPT
));
} else {
parts.push(format!("Marker Files: {markers_str}"));
}
}
parts.join("\n")
}
}
const MARKER_FILES: &[(&str, ProjectType)] = &[
("Cargo.toml", ProjectType::Rust),
("package.json", ProjectType::Node),
("go.mod", ProjectType::Go),
("pyproject.toml", ProjectType::Python),
("requirements.txt", ProjectType::Python),
("CMakeLists.txt", ProjectType::CMake),
(".git", ProjectType::Git),
];
pub fn scan_directory_context() -> Result<DirectoryContext> {
let cwd = env::current_dir().context("Failed to get current directory")?;
scan_directory_context_at(&cwd)
}
fn scan_directory_context_at(path: &Path) -> Result<DirectoryContext> {
debug!(path = %path.display(), "Scanning directory context");
let mut marker_files = Vec::new();
let mut project_type: Option<ProjectType> = None;
let entries = std::fs::read_dir(path)
.with_context(|| format!("Failed to read directory: {}", path.display()))?;
let filenames: Vec<String> = entries
.flatten()
.filter_map(|e| e.file_name().to_str().map(String::from))
.collect();
for (marker, ptype) in MARKER_FILES {
if filenames.contains(&(*marker).to_string()) {
marker_files.push((*marker).to_string());
if project_type.is_none() {
project_type = Some(ptype.clone());
debug!(
marker = %marker,
project_type = ptype.as_str(),
"Detected project type"
);
}
}
}
debug!(
markers = marker_files.len(),
project_type = project_type.as_ref().map_or("None", |p| p.as_str()),
"Directory context scan complete"
);
Ok(DirectoryContext {
project_type,
marker_files,
cwd: path.to_path_buf(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File};
use tempfile::TempDir;
#[test]
fn test_project_type_as_str() {
assert_eq!(ProjectType::Rust.as_str(), "Rust");
assert_eq!(ProjectType::Node.as_str(), "Node.js");
assert_eq!(ProjectType::Python.as_str(), "Python");
assert_eq!(ProjectType::Go.as_str(), "Go");
assert_eq!(ProjectType::CMake.as_str(), "CMake");
assert_eq!(ProjectType::Git.as_str(), "Git");
assert_eq!(ProjectType::Unknown.as_str(), "Unknown");
}
#[test]
fn test_scan_rust_project() {
let temp_dir = TempDir::new().unwrap();
File::create(temp_dir.path().join("Cargo.toml")).unwrap();
let context = scan_directory_context_at(temp_dir.path()).unwrap();
assert_eq!(context.project_type, Some(ProjectType::Rust));
assert!(context.marker_files.contains(&"Cargo.toml".to_string()));
}
#[test]
fn test_scan_node_project() {
let temp_dir = TempDir::new().unwrap();
File::create(temp_dir.path().join("package.json")).unwrap();
let context = scan_directory_context_at(temp_dir.path()).unwrap();
assert_eq!(context.project_type, Some(ProjectType::Node));
assert!(context.marker_files.contains(&"package.json".to_string()));
}
#[test]
fn test_scan_python_project() {
let temp_dir = TempDir::new().unwrap();
File::create(temp_dir.path().join("requirements.txt")).unwrap();
let context = scan_directory_context_at(temp_dir.path()).unwrap();
assert_eq!(context.project_type, Some(ProjectType::Python));
assert!(context
.marker_files
.contains(&"requirements.txt".to_string()));
}
#[test]
fn test_scan_go_project() {
let temp_dir = TempDir::new().unwrap();
File::create(temp_dir.path().join("go.mod")).unwrap();
let context = scan_directory_context_at(temp_dir.path()).unwrap();
assert_eq!(context.project_type, Some(ProjectType::Go));
assert!(context.marker_files.contains(&"go.mod".to_string()));
}
#[test]
fn test_scan_git_repo() {
let temp_dir = TempDir::new().unwrap();
fs::create_dir(temp_dir.path().join(".git")).unwrap();
let context = scan_directory_context_at(temp_dir.path()).unwrap();
assert_eq!(context.project_type, Some(ProjectType::Git));
assert!(context.marker_files.contains(&".git".to_string()));
}
#[test]
fn test_scan_empty_directory() {
let temp_dir = TempDir::new().unwrap();
let context = scan_directory_context_at(temp_dir.path()).unwrap();
assert_eq!(context.project_type, None);
assert!(context.marker_files.is_empty());
}
#[test]
fn test_priority_rust_over_git() {
let temp_dir = TempDir::new().unwrap();
File::create(temp_dir.path().join("Cargo.toml")).unwrap();
fs::create_dir(temp_dir.path().join(".git")).unwrap();
let context = scan_directory_context_at(temp_dir.path()).unwrap();
assert_eq!(context.project_type, Some(ProjectType::Rust));
assert!(context.marker_files.contains(&"Cargo.toml".to_string()));
assert!(context.marker_files.contains(&".git".to_string()));
}
#[test]
fn test_multiple_markers() {
let temp_dir = TempDir::new().unwrap();
File::create(temp_dir.path().join("package.json")).unwrap();
File::create(temp_dir.path().join("requirements.txt")).unwrap();
let context = scan_directory_context_at(temp_dir.path()).unwrap();
assert_eq!(context.project_type, Some(ProjectType::Node));
assert_eq!(context.marker_files.len(), 2);
}
#[test]
fn test_format_for_prompt_basic() {
let context = DirectoryContext {
project_type: Some(ProjectType::Rust),
marker_files: vec!["Cargo.toml".to_string(), ".git".to_string()],
cwd: PathBuf::from("/home/user/project"),
};
let formatted = context.format_for_prompt();
assert!(formatted.contains("Working Directory: /home/user/project"));
assert!(formatted.contains("Project Type: Rust"));
assert!(formatted.contains("Marker Files: Cargo.toml, .git"));
}
#[test]
fn test_format_for_prompt_no_project_type() {
let context = DirectoryContext {
project_type: None,
marker_files: vec![],
cwd: PathBuf::from("/tmp"),
};
let formatted = context.format_for_prompt();
assert!(formatted.contains("Project Type: Unknown"));
assert!(!formatted.contains("Marker Files"));
}
#[test]
fn test_format_for_prompt_marker_limit() {
let markers: Vec<String> = (0..25).map(|i| format!("file{i}.txt")).collect();
let context = DirectoryContext {
project_type: Some(ProjectType::Python),
marker_files: markers,
cwd: PathBuf::from("/project"),
};
let formatted = context.format_for_prompt();
assert!(formatted.contains("(and 5 more)"));
assert!(formatted.contains("file0.txt"));
assert!(formatted.contains("file19.txt"));
}
}