mirror of
https://github.com/samvallad33/vestige.git
synced 2026-04-25 00:36:22 +02:00
985 lines
32 KiB
Rust
985 lines
32 KiB
Rust
|
|
//! Context capture for codebase memory
|
||
|
|
//!
|
||
|
|
//! This module captures the current working context - what branch you're on,
|
||
|
|
//! what files you're editing, what the project structure looks like. This
|
||
|
|
//! context is critical for:
|
||
|
|
//!
|
||
|
|
//! - Storing memories with full context for later retrieval
|
||
|
|
//! - Providing relevant suggestions based on current work
|
||
|
|
//! - Maintaining continuity across sessions
|
||
|
|
|
||
|
|
use std::collections::HashSet;
|
||
|
|
use std::fs;
|
||
|
|
use std::path::{Path, PathBuf};
|
||
|
|
|
||
|
|
use chrono::{DateTime, Utc};
|
||
|
|
use serde::{Deserialize, Serialize};
|
||
|
|
|
||
|
|
use super::git::{GitAnalyzer, GitContext, GitError};
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// ERRORS
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
/// Errors that can occur during context capture
|
||
|
|
#[derive(Debug, thiserror::Error)]
|
||
|
|
pub enum ContextError {
|
||
|
|
#[error("Git error: {0}")]
|
||
|
|
Git(#[from] GitError),
|
||
|
|
#[error("IO error: {0}")]
|
||
|
|
Io(#[from] std::io::Error),
|
||
|
|
#[error("Path not found: {0}")]
|
||
|
|
PathNotFound(PathBuf),
|
||
|
|
}
|
||
|
|
|
||
|
|
pub type Result<T> = std::result::Result<T, ContextError>;
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// PROJECT TYPE DETECTION
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
/// Detected project type based on files present
|
||
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
|
|
#[serde(rename_all = "snake_case")]
|
||
|
|
pub enum ProjectType {
|
||
|
|
Rust,
|
||
|
|
TypeScript,
|
||
|
|
JavaScript,
|
||
|
|
Python,
|
||
|
|
Go,
|
||
|
|
Java,
|
||
|
|
Kotlin,
|
||
|
|
Swift,
|
||
|
|
CSharp,
|
||
|
|
Cpp,
|
||
|
|
Ruby,
|
||
|
|
Php,
|
||
|
|
Mixed(Vec<String>), // Multiple languages detected
|
||
|
|
Unknown,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl ProjectType {
|
||
|
|
/// Get the file extensions associated with this project type
|
||
|
|
pub fn extensions(&self) -> Vec<&'static str> {
|
||
|
|
match self {
|
||
|
|
Self::Rust => vec!["rs"],
|
||
|
|
Self::TypeScript => vec!["ts", "tsx"],
|
||
|
|
Self::JavaScript => vec!["js", "jsx"],
|
||
|
|
Self::Python => vec!["py"],
|
||
|
|
Self::Go => vec!["go"],
|
||
|
|
Self::Java => vec!["java"],
|
||
|
|
Self::Kotlin => vec!["kt", "kts"],
|
||
|
|
Self::Swift => vec!["swift"],
|
||
|
|
Self::CSharp => vec!["cs"],
|
||
|
|
Self::Cpp => vec!["cpp", "cc", "cxx", "c", "h", "hpp"],
|
||
|
|
Self::Ruby => vec!["rb"],
|
||
|
|
Self::Php => vec!["php"],
|
||
|
|
Self::Mixed(_) => vec![],
|
||
|
|
Self::Unknown => vec![],
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Get the language name as a string
|
||
|
|
pub fn language_name(&self) -> &str {
|
||
|
|
match self {
|
||
|
|
Self::Rust => "Rust",
|
||
|
|
Self::TypeScript => "TypeScript",
|
||
|
|
Self::JavaScript => "JavaScript",
|
||
|
|
Self::Python => "Python",
|
||
|
|
Self::Go => "Go",
|
||
|
|
Self::Java => "Java",
|
||
|
|
Self::Kotlin => "Kotlin",
|
||
|
|
Self::Swift => "Swift",
|
||
|
|
Self::CSharp => "C#",
|
||
|
|
Self::Cpp => "C++",
|
||
|
|
Self::Ruby => "Ruby",
|
||
|
|
Self::Php => "PHP",
|
||
|
|
Self::Mixed(_) => "Mixed",
|
||
|
|
Self::Unknown => "Unknown",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// FRAMEWORK DETECTION
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
/// Known frameworks that can be detected
|
||
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
|
|
#[serde(rename_all = "snake_case")]
|
||
|
|
pub enum Framework {
|
||
|
|
// Rust
|
||
|
|
Tauri,
|
||
|
|
Actix,
|
||
|
|
Axum,
|
||
|
|
Rocket,
|
||
|
|
Tokio,
|
||
|
|
Diesel,
|
||
|
|
SeaOrm,
|
||
|
|
|
||
|
|
// JavaScript/TypeScript
|
||
|
|
React,
|
||
|
|
Vue,
|
||
|
|
Angular,
|
||
|
|
Svelte,
|
||
|
|
NextJs,
|
||
|
|
NuxtJs,
|
||
|
|
Express,
|
||
|
|
NestJs,
|
||
|
|
Deno,
|
||
|
|
Bun,
|
||
|
|
|
||
|
|
// Python
|
||
|
|
Django,
|
||
|
|
Flask,
|
||
|
|
FastApi,
|
||
|
|
Pytest,
|
||
|
|
Poetry,
|
||
|
|
|
||
|
|
// Other
|
||
|
|
Spring, // Java
|
||
|
|
Rails, // Ruby
|
||
|
|
Laravel, // PHP
|
||
|
|
DotNet, // C#
|
||
|
|
|
||
|
|
Other(String),
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Framework {
|
||
|
|
pub fn name(&self) -> &str {
|
||
|
|
match self {
|
||
|
|
Self::Tauri => "Tauri",
|
||
|
|
Self::Actix => "Actix",
|
||
|
|
Self::Axum => "Axum",
|
||
|
|
Self::Rocket => "Rocket",
|
||
|
|
Self::Tokio => "Tokio",
|
||
|
|
Self::Diesel => "Diesel",
|
||
|
|
Self::SeaOrm => "SeaORM",
|
||
|
|
Self::React => "React",
|
||
|
|
Self::Vue => "Vue",
|
||
|
|
Self::Angular => "Angular",
|
||
|
|
Self::Svelte => "Svelte",
|
||
|
|
Self::NextJs => "Next.js",
|
||
|
|
Self::NuxtJs => "Nuxt.js",
|
||
|
|
Self::Express => "Express",
|
||
|
|
Self::NestJs => "NestJS",
|
||
|
|
Self::Deno => "Deno",
|
||
|
|
Self::Bun => "Bun",
|
||
|
|
Self::Django => "Django",
|
||
|
|
Self::Flask => "Flask",
|
||
|
|
Self::FastApi => "FastAPI",
|
||
|
|
Self::Pytest => "Pytest",
|
||
|
|
Self::Poetry => "Poetry",
|
||
|
|
Self::Spring => "Spring",
|
||
|
|
Self::Rails => "Rails",
|
||
|
|
Self::Laravel => "Laravel",
|
||
|
|
Self::DotNet => ".NET",
|
||
|
|
Self::Other(name) => name,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// WORKING CONTEXT
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
/// Complete working context for memory storage
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
#[serde(rename_all = "camelCase")]
|
||
|
|
pub struct WorkingContext {
|
||
|
|
/// Git context (branch, commits, changes)
|
||
|
|
pub git: Option<GitContextInfo>,
|
||
|
|
/// Currently active file (e.g., file being edited)
|
||
|
|
pub active_file: Option<PathBuf>,
|
||
|
|
/// Project type (Rust, TypeScript, etc.)
|
||
|
|
pub project_type: ProjectType,
|
||
|
|
/// Detected frameworks
|
||
|
|
pub frameworks: Vec<Framework>,
|
||
|
|
/// Project name (from cargo.toml, package.json, etc.)
|
||
|
|
pub project_name: Option<String>,
|
||
|
|
/// Project root directory
|
||
|
|
pub project_root: PathBuf,
|
||
|
|
/// When this context was captured
|
||
|
|
pub captured_at: DateTime<Utc>,
|
||
|
|
/// Recent files (for context)
|
||
|
|
pub recent_files: Vec<PathBuf>,
|
||
|
|
/// Key configuration files found
|
||
|
|
pub config_files: Vec<PathBuf>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Serializable git context info
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
#[serde(rename_all = "camelCase")]
|
||
|
|
pub struct GitContextInfo {
|
||
|
|
pub current_branch: String,
|
||
|
|
pub head_commit: String,
|
||
|
|
pub uncommitted_changes: Vec<PathBuf>,
|
||
|
|
pub staged_changes: Vec<PathBuf>,
|
||
|
|
pub has_uncommitted: bool,
|
||
|
|
pub is_clean: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl From<GitContext> for GitContextInfo {
|
||
|
|
fn from(ctx: GitContext) -> Self {
|
||
|
|
let has_uncommitted = !ctx.uncommitted_changes.is_empty();
|
||
|
|
let is_clean = ctx.uncommitted_changes.is_empty() && ctx.staged_changes.is_empty();
|
||
|
|
|
||
|
|
Self {
|
||
|
|
current_branch: ctx.current_branch,
|
||
|
|
head_commit: ctx.head_commit,
|
||
|
|
uncommitted_changes: ctx.uncommitted_changes,
|
||
|
|
staged_changes: ctx.staged_changes,
|
||
|
|
has_uncommitted,
|
||
|
|
is_clean,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// FILE CONTEXT
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
/// Context specific to a single file
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
#[serde(rename_all = "camelCase")]
|
||
|
|
pub struct FileContext {
|
||
|
|
/// Path to the file
|
||
|
|
pub path: PathBuf,
|
||
|
|
/// Detected language
|
||
|
|
pub language: Option<String>,
|
||
|
|
/// File extension
|
||
|
|
pub extension: Option<String>,
|
||
|
|
/// Parent directory
|
||
|
|
pub directory: PathBuf,
|
||
|
|
/// Related files (imports, tests, etc.)
|
||
|
|
pub related_files: Vec<PathBuf>,
|
||
|
|
/// Whether the file has uncommitted changes
|
||
|
|
pub has_changes: bool,
|
||
|
|
/// Last modified time
|
||
|
|
pub last_modified: Option<DateTime<Utc>>,
|
||
|
|
/// Whether it's a test file
|
||
|
|
pub is_test_file: bool,
|
||
|
|
/// Module/package this file belongs to
|
||
|
|
pub module: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// CONTEXT CAPTURE
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
/// Captures and manages working context
|
||
|
|
pub struct ContextCapture {
|
||
|
|
/// Git analyzer for the repository
|
||
|
|
git: Option<GitAnalyzer>,
|
||
|
|
/// Currently active files
|
||
|
|
active_files: Vec<PathBuf>,
|
||
|
|
/// Project root directory
|
||
|
|
project_root: PathBuf,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl ContextCapture {
|
||
|
|
/// Create a new context capture for a project directory
|
||
|
|
pub fn new(project_root: PathBuf) -> Result<Self> {
|
||
|
|
// Try to create git analyzer (may fail if not a git repo)
|
||
|
|
let git = GitAnalyzer::new(project_root.clone()).ok();
|
||
|
|
|
||
|
|
Ok(Self {
|
||
|
|
git,
|
||
|
|
active_files: vec![],
|
||
|
|
project_root,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Set the currently active file(s)
|
||
|
|
pub fn set_active_files(&mut self, files: Vec<PathBuf>) {
|
||
|
|
self.active_files = files;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Add an active file
|
||
|
|
pub fn add_active_file(&mut self, file: PathBuf) {
|
||
|
|
if !self.active_files.contains(&file) {
|
||
|
|
self.active_files.push(file);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Remove an active file
|
||
|
|
pub fn remove_active_file(&mut self, file: &Path) {
|
||
|
|
self.active_files.retain(|f| f != file);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Capture the full working context
|
||
|
|
pub fn capture(&self) -> Result<WorkingContext> {
|
||
|
|
let git = self
|
||
|
|
.git
|
||
|
|
.as_ref()
|
||
|
|
.and_then(|g| g.get_current_context().ok().map(GitContextInfo::from));
|
||
|
|
|
||
|
|
let project_type = self.detect_project_type()?;
|
||
|
|
let frameworks = self.detect_frameworks()?;
|
||
|
|
let project_name = self.detect_project_name()?;
|
||
|
|
let config_files = self.find_config_files()?;
|
||
|
|
|
||
|
|
Ok(WorkingContext {
|
||
|
|
git,
|
||
|
|
active_file: self.active_files.first().cloned(),
|
||
|
|
project_type,
|
||
|
|
frameworks,
|
||
|
|
project_name,
|
||
|
|
project_root: self.project_root.clone(),
|
||
|
|
captured_at: Utc::now(),
|
||
|
|
recent_files: self.active_files.clone(),
|
||
|
|
config_files,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Get context specific to a file
|
||
|
|
pub fn context_for_file(&self, path: &Path) -> Result<FileContext> {
|
||
|
|
let extension = path.extension().map(|e| e.to_string_lossy().to_string());
|
||
|
|
|
||
|
|
let language = extension
|
||
|
|
.as_ref()
|
||
|
|
.and_then(|ext| match ext.as_str() {
|
||
|
|
"rs" => Some("rust"),
|
||
|
|
"ts" | "tsx" => Some("typescript"),
|
||
|
|
"js" | "jsx" => Some("javascript"),
|
||
|
|
"py" => Some("python"),
|
||
|
|
"go" => Some("go"),
|
||
|
|
"java" => Some("java"),
|
||
|
|
"kt" | "kts" => Some("kotlin"),
|
||
|
|
"swift" => Some("swift"),
|
||
|
|
"cs" => Some("csharp"),
|
||
|
|
"cpp" | "cc" | "cxx" | "c" => Some("cpp"),
|
||
|
|
"h" | "hpp" => Some("cpp"),
|
||
|
|
"rb" => Some("ruby"),
|
||
|
|
"php" => Some("php"),
|
||
|
|
"sql" => Some("sql"),
|
||
|
|
"json" => Some("json"),
|
||
|
|
"yaml" | "yml" => Some("yaml"),
|
||
|
|
"toml" => Some("toml"),
|
||
|
|
"md" => Some("markdown"),
|
||
|
|
_ => None,
|
||
|
|
})
|
||
|
|
.map(|s| s.to_string());
|
||
|
|
|
||
|
|
let directory = path.parent().unwrap_or(Path::new(".")).to_path_buf();
|
||
|
|
|
||
|
|
// Detect related files
|
||
|
|
let related_files = self.find_related_files(path)?;
|
||
|
|
|
||
|
|
// Check git status
|
||
|
|
let has_changes = self
|
||
|
|
.git
|
||
|
|
.as_ref()
|
||
|
|
.map(|g| {
|
||
|
|
g.get_current_context()
|
||
|
|
.ok()
|
||
|
|
.map(|ctx| {
|
||
|
|
ctx.uncommitted_changes.contains(&path.to_path_buf())
|
||
|
|
|| ctx.staged_changes.contains(&path.to_path_buf())
|
||
|
|
})
|
||
|
|
.unwrap_or(false)
|
||
|
|
})
|
||
|
|
.unwrap_or(false);
|
||
|
|
|
||
|
|
// Check if test file
|
||
|
|
let is_test_file = self.is_test_file(path);
|
||
|
|
|
||
|
|
// Get last modified time
|
||
|
|
let last_modified = fs::metadata(path)
|
||
|
|
.ok()
|
||
|
|
.and_then(|m| m.modified().ok().map(|t| DateTime::<Utc>::from(t)));
|
||
|
|
|
||
|
|
// Detect module
|
||
|
|
let module = self.detect_module(path);
|
||
|
|
|
||
|
|
Ok(FileContext {
|
||
|
|
path: path.to_path_buf(),
|
||
|
|
language,
|
||
|
|
extension,
|
||
|
|
directory,
|
||
|
|
related_files,
|
||
|
|
has_changes,
|
||
|
|
last_modified,
|
||
|
|
is_test_file,
|
||
|
|
module,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Detect the project type based on files present
|
||
|
|
fn detect_project_type(&self) -> Result<ProjectType> {
|
||
|
|
let mut detected = Vec::new();
|
||
|
|
|
||
|
|
// Check for Rust
|
||
|
|
if self.file_exists("Cargo.toml") {
|
||
|
|
detected.push("Rust".to_string());
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for JavaScript/TypeScript
|
||
|
|
if self.file_exists("package.json") {
|
||
|
|
// Check for TypeScript
|
||
|
|
if self.file_exists("tsconfig.json") || self.file_exists("tsconfig.base.json") {
|
||
|
|
detected.push("TypeScript".to_string());
|
||
|
|
} else {
|
||
|
|
detected.push("JavaScript".to_string());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for Python
|
||
|
|
if self.file_exists("pyproject.toml")
|
||
|
|
|| self.file_exists("setup.py")
|
||
|
|
|| self.file_exists("requirements.txt")
|
||
|
|
{
|
||
|
|
detected.push("Python".to_string());
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for Go
|
||
|
|
if self.file_exists("go.mod") {
|
||
|
|
detected.push("Go".to_string());
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for Java/Kotlin
|
||
|
|
if self.file_exists("pom.xml") || self.file_exists("build.gradle") {
|
||
|
|
if self.dir_exists("src/main/kotlin") || self.file_exists("build.gradle.kts") {
|
||
|
|
detected.push("Kotlin".to_string());
|
||
|
|
} else {
|
||
|
|
detected.push("Java".to_string());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for Swift
|
||
|
|
if self.file_exists("Package.swift") {
|
||
|
|
detected.push("Swift".to_string());
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for C#
|
||
|
|
if self.glob_exists("*.csproj") || self.glob_exists("*.sln") {
|
||
|
|
detected.push("CSharp".to_string());
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for Ruby
|
||
|
|
if self.file_exists("Gemfile") {
|
||
|
|
detected.push("Ruby".to_string());
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for PHP
|
||
|
|
if self.file_exists("composer.json") {
|
||
|
|
detected.push("PHP".to_string());
|
||
|
|
}
|
||
|
|
|
||
|
|
match detected.len() {
|
||
|
|
0 => Ok(ProjectType::Unknown),
|
||
|
|
1 => Ok(match detected[0].as_str() {
|
||
|
|
"Rust" => ProjectType::Rust,
|
||
|
|
"TypeScript" => ProjectType::TypeScript,
|
||
|
|
"JavaScript" => ProjectType::JavaScript,
|
||
|
|
"Python" => ProjectType::Python,
|
||
|
|
"Go" => ProjectType::Go,
|
||
|
|
"Java" => ProjectType::Java,
|
||
|
|
"Kotlin" => ProjectType::Kotlin,
|
||
|
|
"Swift" => ProjectType::Swift,
|
||
|
|
"CSharp" => ProjectType::CSharp,
|
||
|
|
"Ruby" => ProjectType::Ruby,
|
||
|
|
"PHP" => ProjectType::Php,
|
||
|
|
_ => ProjectType::Unknown,
|
||
|
|
}),
|
||
|
|
_ => Ok(ProjectType::Mixed(detected)),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Detect frameworks used in the project
|
||
|
|
fn detect_frameworks(&self) -> Result<Vec<Framework>> {
|
||
|
|
let mut frameworks = Vec::new();
|
||
|
|
|
||
|
|
// Rust frameworks
|
||
|
|
if let Ok(content) = fs::read_to_string(self.project_root.join("Cargo.toml")) {
|
||
|
|
if content.contains("tauri") {
|
||
|
|
frameworks.push(Framework::Tauri);
|
||
|
|
}
|
||
|
|
if content.contains("actix-web") {
|
||
|
|
frameworks.push(Framework::Actix);
|
||
|
|
}
|
||
|
|
if content.contains("axum") {
|
||
|
|
frameworks.push(Framework::Axum);
|
||
|
|
}
|
||
|
|
if content.contains("rocket") {
|
||
|
|
frameworks.push(Framework::Rocket);
|
||
|
|
}
|
||
|
|
if content.contains("tokio") {
|
||
|
|
frameworks.push(Framework::Tokio);
|
||
|
|
}
|
||
|
|
if content.contains("diesel") {
|
||
|
|
frameworks.push(Framework::Diesel);
|
||
|
|
}
|
||
|
|
if content.contains("sea-orm") {
|
||
|
|
frameworks.push(Framework::SeaOrm);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// JavaScript/TypeScript frameworks
|
||
|
|
if let Ok(content) = fs::read_to_string(self.project_root.join("package.json")) {
|
||
|
|
if content.contains("\"react\"") || content.contains("\"react\":") {
|
||
|
|
frameworks.push(Framework::React);
|
||
|
|
}
|
||
|
|
if content.contains("\"vue\"") || content.contains("\"vue\":") {
|
||
|
|
frameworks.push(Framework::Vue);
|
||
|
|
}
|
||
|
|
if content.contains("\"@angular/") {
|
||
|
|
frameworks.push(Framework::Angular);
|
||
|
|
}
|
||
|
|
if content.contains("\"svelte\"") {
|
||
|
|
frameworks.push(Framework::Svelte);
|
||
|
|
}
|
||
|
|
if content.contains("\"next\"") || content.contains("\"next\":") {
|
||
|
|
frameworks.push(Framework::NextJs);
|
||
|
|
}
|
||
|
|
if content.contains("\"nuxt\"") || content.contains("\"nuxt\":") {
|
||
|
|
frameworks.push(Framework::NuxtJs);
|
||
|
|
}
|
||
|
|
if content.contains("\"express\"") {
|
||
|
|
frameworks.push(Framework::Express);
|
||
|
|
}
|
||
|
|
if content.contains("\"@nestjs/") {
|
||
|
|
frameworks.push(Framework::NestJs);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Deno
|
||
|
|
if self.file_exists("deno.json") || self.file_exists("deno.jsonc") {
|
||
|
|
frameworks.push(Framework::Deno);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Bun
|
||
|
|
if self.file_exists("bun.lockb") || self.file_exists("bunfig.toml") {
|
||
|
|
frameworks.push(Framework::Bun);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Python frameworks
|
||
|
|
if let Ok(content) = fs::read_to_string(self.project_root.join("pyproject.toml")) {
|
||
|
|
if content.contains("django") {
|
||
|
|
frameworks.push(Framework::Django);
|
||
|
|
}
|
||
|
|
if content.contains("flask") {
|
||
|
|
frameworks.push(Framework::Flask);
|
||
|
|
}
|
||
|
|
if content.contains("fastapi") {
|
||
|
|
frameworks.push(Framework::FastApi);
|
||
|
|
}
|
||
|
|
if content.contains("pytest") {
|
||
|
|
frameworks.push(Framework::Pytest);
|
||
|
|
}
|
||
|
|
if content.contains("[tool.poetry]") {
|
||
|
|
frameworks.push(Framework::Poetry);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check requirements.txt too
|
||
|
|
if let Ok(content) = fs::read_to_string(self.project_root.join("requirements.txt")) {
|
||
|
|
if content.contains("django") && !frameworks.contains(&Framework::Django) {
|
||
|
|
frameworks.push(Framework::Django);
|
||
|
|
}
|
||
|
|
if content.contains("flask") && !frameworks.contains(&Framework::Flask) {
|
||
|
|
frameworks.push(Framework::Flask);
|
||
|
|
}
|
||
|
|
if content.contains("fastapi") && !frameworks.contains(&Framework::FastApi) {
|
||
|
|
frameworks.push(Framework::FastApi);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Java Spring
|
||
|
|
if let Ok(content) = fs::read_to_string(self.project_root.join("pom.xml")) {
|
||
|
|
if content.contains("spring") {
|
||
|
|
frameworks.push(Framework::Spring);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Ruby Rails
|
||
|
|
if self.file_exists("config/routes.rb") {
|
||
|
|
frameworks.push(Framework::Rails);
|
||
|
|
}
|
||
|
|
|
||
|
|
// PHP Laravel
|
||
|
|
if self.file_exists("artisan") && self.dir_exists("app/Http") {
|
||
|
|
frameworks.push(Framework::Laravel);
|
||
|
|
}
|
||
|
|
|
||
|
|
// .NET
|
||
|
|
if self.glob_exists("*.csproj") {
|
||
|
|
frameworks.push(Framework::DotNet);
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(frameworks)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Detect the project name from config files
|
||
|
|
fn detect_project_name(&self) -> Result<Option<String>> {
|
||
|
|
// Try Cargo.toml
|
||
|
|
if let Ok(content) = fs::read_to_string(self.project_root.join("Cargo.toml")) {
|
||
|
|
if let Some(name) = self.extract_toml_value(&content, "name") {
|
||
|
|
return Ok(Some(name));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Try package.json
|
||
|
|
if let Ok(content) = fs::read_to_string(self.project_root.join("package.json")) {
|
||
|
|
if let Some(name) = self.extract_json_value(&content, "name") {
|
||
|
|
return Ok(Some(name));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Try pyproject.toml
|
||
|
|
if let Ok(content) = fs::read_to_string(self.project_root.join("pyproject.toml")) {
|
||
|
|
if let Some(name) = self.extract_toml_value(&content, "name") {
|
||
|
|
return Ok(Some(name));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Try go.mod
|
||
|
|
if let Ok(content) = fs::read_to_string(self.project_root.join("go.mod")) {
|
||
|
|
if let Some(line) = content.lines().next() {
|
||
|
|
if line.starts_with("module ") {
|
||
|
|
let name = line
|
||
|
|
.trim_start_matches("module ")
|
||
|
|
.split('/')
|
||
|
|
.last()
|
||
|
|
.unwrap_or("")
|
||
|
|
.to_string();
|
||
|
|
if !name.is_empty() {
|
||
|
|
return Ok(Some(name));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fall back to directory name
|
||
|
|
Ok(self
|
||
|
|
.project_root
|
||
|
|
.file_name()
|
||
|
|
.map(|n| n.to_string_lossy().to_string()))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Find configuration files in the project
|
||
|
|
fn find_config_files(&self) -> Result<Vec<PathBuf>> {
|
||
|
|
let config_names = [
|
||
|
|
"Cargo.toml",
|
||
|
|
"package.json",
|
||
|
|
"tsconfig.json",
|
||
|
|
"pyproject.toml",
|
||
|
|
"go.mod",
|
||
|
|
".gitignore",
|
||
|
|
".env",
|
||
|
|
".env.local",
|
||
|
|
"docker-compose.yml",
|
||
|
|
"docker-compose.yaml",
|
||
|
|
"Dockerfile",
|
||
|
|
"Makefile",
|
||
|
|
"justfile",
|
||
|
|
".editorconfig",
|
||
|
|
".prettierrc",
|
||
|
|
".eslintrc.json",
|
||
|
|
"rustfmt.toml",
|
||
|
|
".rustfmt.toml",
|
||
|
|
"clippy.toml",
|
||
|
|
".clippy.toml",
|
||
|
|
"tauri.conf.json",
|
||
|
|
];
|
||
|
|
|
||
|
|
let mut found = Vec::new();
|
||
|
|
|
||
|
|
for name in config_names {
|
||
|
|
let path = self.project_root.join(name);
|
||
|
|
if path.exists() {
|
||
|
|
found.push(path);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(found)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Find files related to a given file
|
||
|
|
fn find_related_files(&self, path: &Path) -> Result<Vec<PathBuf>> {
|
||
|
|
let mut related = Vec::new();
|
||
|
|
|
||
|
|
let file_stem = path.file_stem().map(|s| s.to_string_lossy().to_string());
|
||
|
|
let extension = path.extension().map(|s| s.to_string_lossy().to_string());
|
||
|
|
let parent = path.parent();
|
||
|
|
|
||
|
|
if let (Some(stem), Some(parent)) = (file_stem, parent) {
|
||
|
|
// Look for test files
|
||
|
|
let test_patterns = [
|
||
|
|
format!("{}.test", stem),
|
||
|
|
format!("{}_test", stem),
|
||
|
|
format!("{}.spec", stem),
|
||
|
|
format!("test_{}", stem),
|
||
|
|
];
|
||
|
|
|
||
|
|
// Common test directories
|
||
|
|
let test_dirs = ["tests", "test", "__tests__", "spec"];
|
||
|
|
|
||
|
|
// Check same directory for test files
|
||
|
|
if let Ok(entries) = fs::read_dir(parent) {
|
||
|
|
for entry in entries.filter_map(|e| e.ok()) {
|
||
|
|
let entry_path = entry.path();
|
||
|
|
if let Some(entry_stem) = entry_path.file_stem() {
|
||
|
|
let entry_stem = entry_stem.to_string_lossy();
|
||
|
|
for pattern in &test_patterns {
|
||
|
|
if entry_stem.eq_ignore_ascii_case(pattern) {
|
||
|
|
related.push(entry_path.clone());
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check test directories
|
||
|
|
for test_dir in test_dirs {
|
||
|
|
let test_path = self.project_root.join(test_dir);
|
||
|
|
if test_path.exists() {
|
||
|
|
if let Ok(entries) = fs::read_dir(&test_path) {
|
||
|
|
for entry in entries.filter_map(|e| e.ok()) {
|
||
|
|
let entry_path = entry.path();
|
||
|
|
if let Some(entry_stem) = entry_path.file_stem() {
|
||
|
|
let entry_stem = entry_stem.to_string_lossy();
|
||
|
|
if entry_stem.contains(&stem) {
|
||
|
|
related.push(entry_path);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// For Rust, look for mod.rs in same directory
|
||
|
|
if extension.as_deref() == Some("rs") {
|
||
|
|
let mod_path = parent.join("mod.rs");
|
||
|
|
if mod_path.exists() && mod_path != path {
|
||
|
|
related.push(mod_path);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Look for lib.rs or main.rs at project root
|
||
|
|
let lib_path = self.project_root.join("src/lib.rs");
|
||
|
|
let main_path = self.project_root.join("src/main.rs");
|
||
|
|
|
||
|
|
if lib_path.exists() && lib_path != path {
|
||
|
|
related.push(lib_path);
|
||
|
|
}
|
||
|
|
if main_path.exists() && main_path != path {
|
||
|
|
related.push(main_path);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Remove duplicates
|
||
|
|
let related: HashSet<_> = related.into_iter().collect();
|
||
|
|
Ok(related.into_iter().collect())
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Check if a file is a test file
|
||
|
|
fn is_test_file(&self, path: &Path) -> bool {
|
||
|
|
let path_str = path.to_string_lossy().to_lowercase();
|
||
|
|
|
||
|
|
path_str.contains("test")
|
||
|
|
|| path_str.contains("spec")
|
||
|
|
|| path_str.contains("__tests__")
|
||
|
|
|| path
|
||
|
|
.file_name()
|
||
|
|
.map(|n| {
|
||
|
|
let n = n.to_string_lossy();
|
||
|
|
n.starts_with("test_")
|
||
|
|
|| n.ends_with("_test.rs")
|
||
|
|
|| n.ends_with(".test.ts")
|
||
|
|
|| n.ends_with(".test.tsx")
|
||
|
|
|| n.ends_with(".test.js")
|
||
|
|
|| n.ends_with(".spec.ts")
|
||
|
|
|| n.ends_with(".spec.js")
|
||
|
|
})
|
||
|
|
.unwrap_or(false)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Detect the module a file belongs to
|
||
|
|
fn detect_module(&self, path: &Path) -> Option<String> {
|
||
|
|
// For Rust, use the parent directory name relative to src/
|
||
|
|
if path.extension().map(|e| e == "rs").unwrap_or(false) {
|
||
|
|
if let Ok(relative) = path.strip_prefix(&self.project_root) {
|
||
|
|
if let Ok(src_relative) = relative.strip_prefix("src") {
|
||
|
|
// Get the module path
|
||
|
|
let components: Vec<_> = src_relative
|
||
|
|
.parent()?
|
||
|
|
.components()
|
||
|
|
.map(|c| c.as_os_str().to_string_lossy().to_string())
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
if !components.is_empty() {
|
||
|
|
return Some(components.join("::"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// For TypeScript/JavaScript, use the parent directory
|
||
|
|
if path
|
||
|
|
.extension()
|
||
|
|
.map(|e| e == "ts" || e == "tsx" || e == "js" || e == "jsx")
|
||
|
|
.unwrap_or(false)
|
||
|
|
{
|
||
|
|
if let Ok(relative) = path.strip_prefix(&self.project_root) {
|
||
|
|
// Skip src/ or lib/ prefix
|
||
|
|
let relative = relative
|
||
|
|
.strip_prefix("src")
|
||
|
|
.or_else(|_| relative.strip_prefix("lib"))
|
||
|
|
.unwrap_or(relative);
|
||
|
|
|
||
|
|
if let Some(parent) = relative.parent() {
|
||
|
|
let module = parent.to_string_lossy().replace('/', ".");
|
||
|
|
if !module.is_empty() {
|
||
|
|
return Some(module);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
None
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Check if a file exists relative to project root
|
||
|
|
fn file_exists(&self, name: &str) -> bool {
|
||
|
|
self.project_root.join(name).exists()
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Check if a directory exists relative to project root
|
||
|
|
fn dir_exists(&self, name: &str) -> bool {
|
||
|
|
let path = self.project_root.join(name);
|
||
|
|
path.exists() && path.is_dir()
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Check if any file matching a glob pattern exists
|
||
|
|
fn glob_exists(&self, pattern: &str) -> bool {
|
||
|
|
if let Ok(entries) = fs::read_dir(&self.project_root) {
|
||
|
|
for entry in entries.filter_map(|e| e.ok()) {
|
||
|
|
if let Some(name) = entry.file_name().to_str() {
|
||
|
|
// Simple glob matching for patterns like "*.ext"
|
||
|
|
if pattern.starts_with("*.") {
|
||
|
|
let ext = &pattern[1..];
|
||
|
|
if name.ends_with(ext) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
false
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Simple TOML value extraction (basic, no full parser)
|
||
|
|
fn extract_toml_value(&self, content: &str, key: &str) -> Option<String> {
|
||
|
|
for line in content.lines() {
|
||
|
|
let trimmed = line.trim();
|
||
|
|
if trimmed.starts_with(&format!("{} ", key))
|
||
|
|
|| trimmed.starts_with(&format!("{}=", key))
|
||
|
|
{
|
||
|
|
if let Some(value) = trimmed.split('=').nth(1) {
|
||
|
|
let value = value.trim().trim_matches('"').trim_matches('\'');
|
||
|
|
return Some(value.to_string());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
None
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Simple JSON value extraction (basic, no full parser)
|
||
|
|
fn extract_json_value(&self, content: &str, key: &str) -> Option<String> {
|
||
|
|
let pattern = format!("\"{}\"", key);
|
||
|
|
for line in content.lines() {
|
||
|
|
if line.contains(&pattern) {
|
||
|
|
// Try to extract the value after the colon
|
||
|
|
if let Some(colon_pos) = line.find(':') {
|
||
|
|
let value = line[colon_pos + 1..].trim();
|
||
|
|
let value = value.trim_start_matches('"');
|
||
|
|
if let Some(end) = value.find('"') {
|
||
|
|
return Some(value[..end].to_string());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
None
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// TESTS
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
use tempfile::TempDir;
|
||
|
|
|
||
|
|
fn create_test_project() -> TempDir {
|
||
|
|
let dir = TempDir::new().unwrap();
|
||
|
|
|
||
|
|
// Create Cargo.toml
|
||
|
|
fs::write(
|
||
|
|
dir.path().join("Cargo.toml"),
|
||
|
|
r#"
|
||
|
|
[package]
|
||
|
|
name = "test-project"
|
||
|
|
version = "0.1.0"
|
||
|
|
|
||
|
|
[dependencies]
|
||
|
|
tokio = "1.0"
|
||
|
|
axum = "0.7"
|
||
|
|
"#,
|
||
|
|
)
|
||
|
|
.unwrap();
|
||
|
|
|
||
|
|
// Create src directory
|
||
|
|
fs::create_dir(dir.path().join("src")).unwrap();
|
||
|
|
fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
|
||
|
|
|
||
|
|
dir
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_detect_project_type() {
|
||
|
|
let dir = create_test_project();
|
||
|
|
let capture = ContextCapture::new(dir.path().to_path_buf()).unwrap();
|
||
|
|
|
||
|
|
let project_type = capture.detect_project_type().unwrap();
|
||
|
|
assert_eq!(project_type, ProjectType::Rust);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_detect_frameworks() {
|
||
|
|
let dir = create_test_project();
|
||
|
|
let capture = ContextCapture::new(dir.path().to_path_buf()).unwrap();
|
||
|
|
|
||
|
|
let frameworks = capture.detect_frameworks().unwrap();
|
||
|
|
assert!(frameworks.contains(&Framework::Tokio));
|
||
|
|
assert!(frameworks.contains(&Framework::Axum));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_detect_project_name() {
|
||
|
|
let dir = create_test_project();
|
||
|
|
let capture = ContextCapture::new(dir.path().to_path_buf()).unwrap();
|
||
|
|
|
||
|
|
let name = capture.detect_project_name().unwrap();
|
||
|
|
assert_eq!(name, Some("test-project".to_string()));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_is_test_file() {
|
||
|
|
let capture = ContextCapture {
|
||
|
|
git: None,
|
||
|
|
active_files: vec![],
|
||
|
|
project_root: PathBuf::from("."),
|
||
|
|
};
|
||
|
|
|
||
|
|
assert!(capture.is_test_file(Path::new("src/utils_test.rs")));
|
||
|
|
assert!(capture.is_test_file(Path::new("tests/integration.rs")));
|
||
|
|
assert!(capture.is_test_file(Path::new("src/utils.test.ts")));
|
||
|
|
assert!(!capture.is_test_file(Path::new("src/utils.rs")));
|
||
|
|
assert!(!capture.is_test_file(Path::new("src/main.ts")));
|
||
|
|
}
|
||
|
|
}
|