use anyhow::Result;
use async_trait::async_trait;
use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tokio::process::Command;
use tokio::sync::{broadcast, mpsc, RwLock};
use tokio::time::{sleep, timeout};
use super::{Tool, ToolDefinition};
use crate::approval::RiskLevel;
use crate::cancel::CancellationToken;
use crate::constants::{CODEGRAPH_CLI_TIMEOUT_SECS, CODEGRAPH_SYNC_INTERVAL_SECS};
use crate::memory::ProjectStructureAnalyzer;
#[derive(Debug, Serialize, Deserialize)]
pub struct Node {
pub id: String,
pub kind: String,
pub name: String,
pub qualified_name: String,
pub file_path: String,
pub language: String,
pub start_line: u32,
pub end_line: u32,
pub start_column: u32,
pub end_column: u32,
pub signature: Option<String>,
pub docstring: Option<String>,
pub visibility: Option<String>,
pub is_exported: bool,
pub is_async: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Edge {
pub source: String,
pub target: String,
pub kind: String,
pub line: Option<u32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct IndexStatus {
pub initialized: bool,
pub file_count: u32,
pub node_count: u32,
pub edge_count: u32,
pub languages: Vec<String>,
pub pending_changes: PendingChanges,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PendingChanges {
pub added: u32,
pub modified: u32,
pub removed: u32,
}
fn get_codegraph_install_dir() -> Option<PathBuf> {
dirs::data_local_dir()
.map(|p| p.join("codegraph").join("current").join("bin"))
}
fn get_codegraph_exe_name() -> String {
if cfg!(windows) {
"codegraph.cmd".to_string()
} else {
"codegraph".to_string()
}
}
pub fn is_codegraph_installed() -> bool {
if std::process::Command::new("codegraph")
.arg("--version")
.output()
.is_ok() {
return true;
}
if let Some(install_dir) = get_codegraph_install_dir() {
let exe_name = get_codegraph_exe_name();
let exe_path = install_dir.join(&exe_name);
if exe_path.exists()
&& std::process::Command::new(&exe_path)
.arg("--version")
.output()
.is_ok() {
return true;
}
}
false
}
pub fn get_codegraph_path() -> Option<String> {
if std::process::Command::new("codegraph")
.arg("--version")
.output()
.is_ok() {
return Some("codegraph".to_string());
}
if let Some(install_dir) = get_codegraph_install_dir() {
let exe_name = get_codegraph_exe_name();
let exe_path = install_dir.join(&exe_name);
if exe_path.exists() {
return Some(exe_path.to_string_lossy().to_string());
}
}
None
}
pub async fn install_codegraph() -> Result<()> {
log::info!("Installing CodeGraph CLI...");
let result = Command::new("powershell")
.args([
"-NoProfile",
"-Command",
"irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex"
])
.output()
.await?;
if result.status.success() {
log::info!("CodeGraph CLI installed successfully");
Ok(())
} else {
let stderr = String::from_utf8_lossy(&result.stderr);
Err(anyhow::anyhow!("CodeGraph installation failed: {}", stderr))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CodeGraphInstallStatus {
Installed(String),
NotInstalled,
}
pub fn check_codegraph_status() -> CodeGraphInstallStatus {
match get_codegraph_path() {
Some(path) => CodeGraphInstallStatus::Installed(path),
None => CodeGraphInstallStatus::NotInstalled,
}
}
pub async fn ensure_codegraph() -> Result<String> {
if let Some(path) = get_codegraph_path() {
return Ok(path);
}
install_codegraph().await?;
get_codegraph_path()
.ok_or_else(|| anyhow::anyhow!("CodeGraph installation failed - please install manually"))
}
pub async fn ensure_codegraph_with_prompt(prompt_fn: impl FnOnce() -> bool) -> Result<Option<String>> {
match get_codegraph_path() {
Some(path) => Ok(Some(path)),
None => {
if prompt_fn() {
install_codegraph().await?;
get_codegraph_path()
.ok_or_else(|| anyhow::anyhow!("CodeGraph installation failed - please install manually"))
.map(Some)
} else {
Ok(None)
}
}
}
}
pub struct CodeGraphManager {
project_path: PathBuf,
db_path: PathBuf,
}
impl CodeGraphManager {
pub fn new(project_path: &Path) -> Self {
let db_path = project_path.join(".codegraph").join("codegraph.db");
Self {
project_path: project_path.to_path_buf(),
db_path,
}
}
pub fn with_auto_detect(start_path: &Path) -> Self {
let project_path = find_project_root(start_path);
Self::new(&project_path)
}
pub fn is_initialized(&self) -> bool {
self.db_path.exists()
}
pub fn connect(&self) -> Result<Connection> {
let conn = Connection::open(&self.db_path)?;
conn.execute_batch("PRAGMA query_only = ON;")?;
Ok(conn)
}
pub async fn init(&self) -> Result<()> {
self.run_cli_command(&["init", "-i"]).await?;
Ok(())
}
pub async fn reinit(&self) -> Result<()> {
if get_codegraph_path().is_none() {
return Err(anyhow::anyhow!(
"CodeGraph CLI not installed. Please install first or use init with --install flag."
));
}
let codegraph_dir = self.project_path.join(".codegraph");
if codegraph_dir.exists() {
log::info!("CodeGraph: deleting old index at {}", codegraph_dir.display());
std::fs::remove_dir_all(&codegraph_dir)?;
}
let matrix_md_path = self.project_path.join(".matrix").join("matrix.md");
if matrix_md_path.exists() {
log::info!("CodeGraph: deleting old matrix.md at {}", matrix_md_path.display());
std::fs::remove_file(&matrix_md_path)?;
}
log::info!("CodeGraph: rebuilding index for {}", self.project_path.display());
self.init().await?;
self.sync().await?;
update_version_after_sync(&self.project_path);
Ok(())
}
pub async fn sync(&self) -> Result<()> {
self.run_cli_command(&["sync"]).await?;
Ok(())
}
async fn run_cli_command(&self, args: &[&str]) -> Result<()> {
let codegraph_path = get_codegraph_path()
.ok_or_else(|| anyhow::anyhow!("CodeGraph CLI not installed. Run 'codegraph install' or use matrixcode to auto-install."))?;
timeout(Duration::from_secs(CODEGRAPH_CLI_TIMEOUT_SECS), async {
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let mut std_cmd = std::process::Command::new(&codegraph_path);
std_cmd.args(args)
.current_dir(&self.project_path)
.creation_flags(CREATE_NO_WINDOW);
let result = std_cmd.output()?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
return Err(anyhow::anyhow!("CodeGraph command failed: {}", stderr));
}
Ok::<_, anyhow::Error>(())
}
#[cfg(not(target_os = "windows"))]
{
let result = Command::new(&codegraph_path)
.args(args)
.current_dir(&self.project_path)
.output()
.await?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
return Err(anyhow::anyhow!("CodeGraph command failed: {}", stderr));
}
Ok::<_, anyhow::Error>(())
}
})
.await
.map_err(|_| anyhow::anyhow!(format!("CodeGraph CLI timeout ({})s", CODEGRAPH_CLI_TIMEOUT_SECS)))?
}
pub async fn ensure_initialized(&self) -> Result<()> {
ensure_codegraph().await?;
let analyzer = ProjectStructureAnalyzer::new(self.project_path.clone());
if analyzer.detect_project_type().is_none() {
return Err(anyhow::anyhow!(
"Not a code project directory: {}. CodeGraph requires a project with Cargo.toml, package.json, go.mod, etc.",
self.project_path.display()
));
}
if !self.is_initialized() {
log::info!("Initializing CodeGraph for: {}", self.project_path.display());
self.init().await?;
}
Ok(())
}
pub fn search(&self, pattern: &str, limit: usize) -> Result<Vec<Node>> {
let conn = self.connect()?;
let mut stmt = conn.prepare(
"SELECT id, kind, name, qualified_name, file_path, language,
start_line, end_line, start_column, end_column,
signature, docstring, visibility, is_exported, is_async
FROM nodes
WHERE name LIKE ? OR qualified_name LIKE ?
ORDER BY name
LIMIT ?"
)?;
let pattern = format!("%{}%", pattern);
let nodes = stmt.query_map(params![&pattern, &pattern, limit], |row| {
Ok(Node {
id: row.get(0)?,
kind: row.get(1)?,
name: row.get(2)?,
qualified_name: row.get(3)?,
file_path: row.get(4)?,
language: row.get(5)?,
start_line: row.get(6)?,
end_line: row.get(7)?,
start_column: row.get(8)?,
end_column: row.get(9)?,
signature: row.get(10)?,
docstring: row.get(11)?,
visibility: row.get(12)?,
is_exported: row.get::<_, i32>(13)? != 0,
is_async: row.get::<_, i32>(14)? != 0,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(nodes)
}
pub fn callers(&self, symbol_id: &str, limit: usize) -> Result<Vec<Node>> {
let conn = self.connect()?;
let mut stmt = conn.prepare(
"SELECT n.id, n.kind, n.name, n.qualified_name, n.file_path, n.language,
n.start_line, n.end_line, n.start_column, n.end_column,
n.signature, n.docstring, n.visibility, n.is_exported, n.is_async
FROM nodes n
INNER JOIN edges e ON n.id = e.source
WHERE e.target = ? AND e.kind = 'calls'
LIMIT ?"
)?;
let nodes = stmt.query_map(params![symbol_id, limit], |row| {
Ok(Node {
id: row.get(0)?,
kind: row.get(1)?,
name: row.get(2)?,
qualified_name: row.get(3)?,
file_path: row.get(4)?,
language: row.get(5)?,
start_line: row.get(6)?,
end_line: row.get(7)?,
start_column: row.get(8)?,
end_column: row.get(9)?,
signature: row.get(10)?,
docstring: row.get(11)?,
visibility: row.get(12)?,
is_exported: row.get::<_, i32>(13)? != 0,
is_async: row.get::<_, i32>(14)? != 0,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(nodes)
}
pub fn callees(&self, symbol_id: &str, limit: usize) -> Result<Vec<Node>> {
let conn = self.connect()?;
let mut stmt = conn.prepare(
"SELECT n.id, n.kind, n.name, n.qualified_name, n.file_path, n.language,
n.start_line, n.end_line, n.start_column, n.end_column,
n.signature, n.docstring, n.visibility, n.is_exported, n.is_async
FROM nodes n
INNER JOIN edges e ON n.id = e.target
WHERE e.source = ? AND e.kind = 'calls'
LIMIT ?"
)?;
let nodes = stmt.query_map(params![symbol_id, limit], |row| {
Ok(Node {
id: row.get(0)?,
kind: row.get(1)?,
name: row.get(2)?,
qualified_name: row.get(3)?,
file_path: row.get(4)?,
language: row.get(5)?,
start_line: row.get(6)?,
end_line: row.get(7)?,
start_column: row.get(8)?,
end_column: row.get(9)?,
signature: row.get(10)?,
docstring: row.get(11)?,
visibility: row.get(12)?,
is_exported: row.get::<_, i32>(13)? != 0,
is_async: row.get::<_, i32>(14)? != 0,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(nodes)
}
pub fn status(&self) -> Result<IndexStatus> {
if !self.is_initialized() {
return Ok(IndexStatus {
initialized: false,
file_count: 0,
node_count: 0,
edge_count: 0,
languages: vec![],
pending_changes: PendingChanges {
added: 0,
modified: 0,
removed: 0,
},
});
}
let conn = self.connect()?;
let file_count: u32 = conn.query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0))?;
let node_count: u32 = conn.query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0))?;
let edge_count: u32 = conn.query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))?;
let mut stmt = conn.prepare("SELECT DISTINCT language FROM nodes")?;
let languages: Vec<String> = stmt.query_map([], |r| r.get(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(IndexStatus {
initialized: true,
file_count,
node_count,
edge_count,
languages,
pending_changes: PendingChanges {
added: 0,
modified: 0,
removed: 0,
},
})
}
pub fn files(&self, language: Option<&str>) -> Result<Vec<FileInfo>> {
let conn = self.connect()?;
let mut stmt = if let Some(_lang) = language {
conn.prepare(
"SELECT path, language, node_count FROM files WHERE language = ?"
)?
} else {
conn.prepare("SELECT path, language, node_count FROM files")?
};
let files = if let Some(lang) = language {
stmt.query_map(params![lang], |row| {
Ok(FileInfo {
path: row.get(0)?,
language: row.get(1)?,
node_count: row.get(2)?,
})
})?
.collect::<Result<Vec<_>, _>>()?
} else {
stmt.query_map([], |row| {
Ok(FileInfo {
path: row.get(0)?,
language: row.get(1)?,
node_count: row.get(2)?,
})
})?
.collect::<Result<Vec<_>, _>>()?
};
Ok(files)
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FileInfo {
pub path: String,
pub language: String,
pub node_count: u32,
}
const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
"target", "dist", "build", "out", "bin", "obj", ".output",
"node_modules", "vendor", "Pods", ".venv", "venv", "__pycache__",
".cache", ".tmp", ".temp", "tmp", "temp",
".idea", ".vscode", ".eclipse", ".project", ".classpath",
".generated", "generated", ".codegraph",
"package-lock.json", "yarn.lock", "Cargo.lock", "pnpm-lock.yaml",
"coverage", ".nyc_output", "test-results",
"logs",
];
const WATCH_EXTENSIONS: &[&str] = &[
"rs", "ts", "tsx", "js", "jsx", "mjs", "py", "go",
"java", "kt", "kts", "c", "cpp", "cc", "h", "hpp",
"rb", "php", "swift", "cs", "scala", "lua", "sh",
];
const GIT_STATUS_POLL_INTERVAL_SECS: u64 = 2;
fn is_git_repository(project_path: &Path) -> bool {
std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(project_path)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn get_git_head_sha(project_path: &Path) -> Option<String> {
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(project_path)
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
}
#[allow(dead_code)]
fn get_git_tracked_files(project_path: &Path) -> Vec<PathBuf> {
std::process::Command::new("git")
.args(["ls-files"])
.current_dir(project_path)
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(
String::from_utf8_lossy(&o.stdout)
.lines()
.filter_map(|line| {
let path = project_path.join(line);
if is_source_file(&path) {
Some(path)
} else {
None
}
})
.collect(),
)
} else {
None
}
})
.unwrap_or_default()
}
fn get_git_status_changes(project_path: &Path) -> GitStatusChanges {
let output = std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(project_path)
.output();
let mut changes = GitStatusChanges::default();
if let Ok(o) = output
&& o.status.success() {
for line in String::from_utf8_lossy(&o.stdout).lines() {
if line.len() < 2 {
continue;
}
let status = &line[..2];
let path = line[3..].trim();
let file_path = if path.contains(" -> ") {
path.split(" -> ").nth(1).unwrap_or(path)
} else {
path
};
let full_path = project_path.join(file_path);
if !is_source_file(&full_path) {
continue;
}
match status.trim() {
"M" | "MM" | "AM" => changes.modified.push(full_path),
"A" | "??" => changes.added.push(full_path),
"D" | "AD" | "MD" => changes.deleted.push(full_path),
"R" => {
if let Some(old_path) = path.split(" -> ").next() {
changes.deleted.push(project_path.join(old_path));
}
changes.added.push(full_path);
}
_ => {}
}
}
}
changes
}
fn start_git_fsmonitor(project_path: &Path) -> bool {
std::process::Command::new("git")
.args(["fsmonitor--daemon", "start"])
.current_dir(project_path)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn is_git_fsmonitor_running(project_path: &Path) -> bool {
std::process::Command::new("git")
.args(["fsmonitor--daemon", "status"])
.current_dir(project_path)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[derive(Debug, Default)]
struct GitStatusChanges {
modified: Vec<PathBuf>,
added: Vec<PathBuf>,
deleted: Vec<PathBuf>,
}
impl GitStatusChanges {
fn has_changes(&self) -> bool {
!self.modified.is_empty() || !self.added.is_empty() || !self.deleted.is_empty()
}
#[allow(dead_code)]
fn total_count(&self) -> usize {
self.modified.len() + self.added.len() + self.deleted.len()
}
}
const PROJECT_ROOT_MARKERS: &[&str] = &[
".git",
"Cargo.toml",
"package.json",
"tsconfig.json",
"go.mod",
"pyproject.toml",
"setup.py",
"requirements.txt",
"pom.xml",
"build.gradle",
"composer.json",
"Gemfile",
];
pub fn find_project_root(start_path: &Path) -> PathBuf {
if let Some(git_root) = find_git_root(start_path) {
return git_root;
}
if let Some(project_root) = find_by_markers(start_path) {
return project_root;
}
start_path.to_path_buf()
}
fn find_git_root(start_path: &Path) -> Option<PathBuf> {
let mut current = start_path;
while let Some(parent) = current.parent() {
if current.join(".git").exists() {
return Some(current.to_path_buf());
}
current = parent;
}
if start_path.join(".git").exists() {
return Some(start_path.to_path_buf());
}
None
}
fn find_by_markers(start_path: &Path) -> Option<PathBuf> {
let mut current = start_path;
loop {
for marker in PROJECT_ROOT_MARKERS {
if current.join(marker).exists() {
return Some(current.to_path_buf());
}
}
if let Some(parent) = current.parent() {
current = parent;
} else {
break;
}
}
None
}
const WATCHER_LOCK_FILE: &str = "watcher.lock";
const SYNC_LOCK_FILE: &str = "sync.lock";
const LOCK_TIMEOUT_SECS: u64 = 30;
fn get_instance_id() -> String {
use std::process;
format!("{}-{}", process::id(), chrono::Utc::now().timestamp())
}
#[derive(Debug, Clone)]
struct WatcherLock {
instance_id: String,
acquired_at: i64,
pid: u32,
}
impl WatcherLock {
fn new() -> Self {
Self {
instance_id: get_instance_id(),
acquired_at: chrono::Utc::now().timestamp(),
pid: std::process::id(),
}
}
fn encode(&self) -> String {
format!("{}:{}:{}",
self.instance_id,
self.acquired_at,
self.pid
)
}
fn decode(s: &str) -> Option<Self> {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() >= 3 {
Some(Self {
instance_id: parts[0].to_string(),
acquired_at: parts[1].parse().ok()?,
pid: parts[2].parse().ok()?,
})
} else {
None
}
}
fn is_stale(&self) -> bool {
let now = chrono::Utc::now().timestamp();
if now - self.acquired_at > LOCK_TIMEOUT_SECS as i64 {
return true;
}
false
}
}
pub fn try_acquire_watcher_lock(project_path: &Path) -> bool {
let lock_path = project_path.join(".codegraph").join(WATCHER_LOCK_FILE);
if let Some(parent) = lock_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if lock_path.exists() {
let content = std::fs::read_to_string(&lock_path).ok();
if let Some(s) = content
&& let Some(lock) = WatcherLock::decode(&s) {
if !lock.is_stale() {
log::info!(
"CodeGraph: watcher lock held by instance {} (PID {}), skipping",
lock.instance_id,
lock.pid
);
return false;
}
log::info!(
"CodeGraph: stealing stale watcher lock from instance {} (PID {})",
lock.instance_id,
lock.pid
);
}
}
let lock = WatcherLock::new();
let _ = std::fs::write(&lock_path, lock.encode());
log::info!("CodeGraph: acquired watcher lock (instance {})", lock.instance_id);
true
}
pub fn release_watcher_lock(project_path: &Path) {
let lock_path = project_path.join(".codegraph").join(WATCHER_LOCK_FILE);
if lock_path.exists() {
let _ = std::fs::remove_file(&lock_path);
log::info!("CodeGraph: released watcher lock");
}
}
fn update_watcher_heartbeat(project_path: &Path) {
let lock_path = project_path.join(".codegraph").join(WATCHER_LOCK_FILE);
if lock_path.exists() {
let lock = WatcherLock::new();
let _ = std::fs::write(&lock_path, lock.encode());
}
}
fn try_acquire_sync_lock(project_path: &Path) -> bool {
let lock_path = project_path.join(".codegraph").join(SYNC_LOCK_FILE);
if lock_path.exists() {
let content = std::fs::read_to_string(&lock_path).ok();
if let Some(s) = content {
let timestamp: i64 = s.parse().ok().unwrap_or(0);
let now = chrono::Utc::now().timestamp();
if now - timestamp < 5 {
log::debug!("CodeGraph: sync in progress by another instance, skipping");
return false;
}
}
}
let timestamp = chrono::Utc::now().timestamp().to_string();
let _ = std::fs::write(&lock_path, timestamp);
true
}
fn release_sync_lock(project_path: &Path) {
let lock_path = project_path.join(".codegraph").join(SYNC_LOCK_FILE);
if lock_path.exists() {
let _ = std::fs::remove_file(&lock_path);
}
}
const VERSION_FILE: &str = "version.txt";
fn save_version_sha(project_path: &Path, sha: &str) {
let version_path = project_path.join(".codegraph").join(VERSION_FILE);
if let Some(parent) = version_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&version_path, sha);
}
fn load_version_sha(project_path: &Path) -> Option<String> {
let version_path = project_path.join(".codegraph").join(VERSION_FILE);
std::fs::read_to_string(&version_path)
.ok()
.map(|s| s.trim().to_string())
}
fn has_version_changed(project_path: &Path) -> bool {
let current_sha = get_git_head_sha(project_path);
let stored_sha = load_version_sha(project_path);
current_sha != stored_sha
}
fn update_version_after_sync(project_path: &Path) {
if let Some(sha) = get_git_head_sha(project_path) {
save_version_sha(project_path, &sha);
log::debug!("CodeGraph: version updated to SHA {}", sha);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CodeGraphEnv {
Git,
NonGit,
}
fn detect_env_type(project_path: &Path) -> CodeGraphEnv {
if is_git_repository(project_path) {
CodeGraphEnv::Git
} else {
CodeGraphEnv::NonGit
}
}
pub struct IgnoreMatcher {
patterns: Vec<String>,
negation_patterns: Vec<String>,
}
impl IgnoreMatcher {
pub fn load(project_path: &Path) -> Self {
let mut patterns = Vec::new();
let mut negation_patterns = Vec::new();
for p in DEFAULT_IGNORE_PATTERNS {
patterns.push(p.to_string());
}
let gitignore_path = project_path.join(".gitignore");
if gitignore_path.exists()
&& let Ok(content) = std::fs::read_to_string(&gitignore_path) {
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(stripped) = line.strip_prefix('!') {
negation_patterns.push(stripped.to_string());
} else {
patterns.push(line.to_string());
}
}
}
Self { patterns, negation_patterns }
}
pub fn should_ignore(&self, path: &Path, project_path: &Path) -> bool {
let path_str = path.to_string_lossy();
let relative_path = path.strip_prefix(project_path)
.unwrap_or(path)
.to_string_lossy();
for pattern in &self.negation_patterns {
if Self::matches_pattern(&relative_path, pattern) {
return false; }
}
for pattern in &self.patterns {
if Self::matches_pattern(&relative_path, pattern)
|| path_str.contains(pattern) {
return true;
}
}
for component in path.components() {
if let std::path::Component::Normal(name) = component {
let name_str = name.to_string_lossy();
if name_str.starts_with('.')
&& name_str != ".codegraph"
&& !WATCH_EXTENSIONS.contains(&name_str.split('.').next_back().unwrap_or("")) {
return true;
}
}
}
false
}
fn matches_pattern(path: &str, pattern: &str) -> bool {
let pattern = pattern.trim_start_matches('/');
if let Some(dir_pattern) = pattern.strip_suffix('/') {
return path.contains(dir_pattern) || path.starts_with(dir_pattern);
}
if pattern.contains('*') {
let parts = pattern.split('*').collect::<Vec<_>>();
if parts.len() == 2 {
let prefix = parts[0];
let suffix = parts[1];
return (prefix.is_empty() || path.starts_with(prefix))
&& (suffix.is_empty() || path.ends_with(suffix));
}
}
path == pattern || path.contains(pattern) || path.starts_with(&format!("{}/", pattern))
}
}
fn is_source_file(path: &Path) -> bool {
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
return WATCH_EXTENSIONS.contains(&ext_str.as_str());
}
false
}
pub struct CodeGraphWatcher {
project_path: PathBuf,
stop_tx: broadcast::Sender<()>,
sync_interval: Duration,
}
impl CodeGraphWatcher {
pub fn new(project_path: &Path) -> Self {
let (stop_tx, _) = broadcast::channel(1);
Self {
project_path: project_path.to_path_buf(),
stop_tx,
sync_interval: Duration::from_secs(CODEGRAPH_SYNC_INTERVAL_SECS), }
}
pub fn with_auto_detect(start_path: &Path) -> Self {
let project_path = find_project_root(start_path);
log::info!("CodeGraph: detected project root at {}", project_path.display());
Self::new(&project_path)
}
pub fn start(&self, cancel_token: CancellationToken) -> tokio::task::JoinHandle<()> {
let project_path = self.project_path.clone();
let sync_interval = self.sync_interval;
tokio::spawn(async move {
Self::run_watcher_loop(project_path, sync_interval, cancel_token).await;
})
}
pub fn stop(&self) {
let _ = self.stop_tx.send(());
}
async fn run_watcher_loop(
project_path: PathBuf,
_sync_interval: Duration,
cancel_token: CancellationToken,
) {
if get_codegraph_path().is_none() {
log::warn!("CodeGraph CLI not found, watcher disabled. Please install CodeGraph manually.");
return;
}
if !try_acquire_watcher_lock(&project_path) {
log::info!("CodeGraph: another instance is watching this project, exiting");
return;
}
let analyzer = ProjectStructureAnalyzer::new(project_path.clone());
if analyzer.detect_project_type().is_none() {
log::info!(
"CodeGraph: skipping non-code directory: {}",
project_path.display()
);
return;
}
let manager = CodeGraphManager::new(&project_path);
if !manager.is_initialized() {
log::info!(
"CodeGraph: not initialized for {}, skipping watcher. Run 'codegraph init -i' to create index.",
project_path.display()
);
release_watcher_lock(&project_path);
return;
}
let env_type = detect_env_type(&project_path);
log::info!(
"CodeGraph: environment detected as {} for: {}",
match env_type {
CodeGraphEnv::Git => "Git repository",
CodeGraphEnv::NonGit => "non-Git directory",
},
project_path.display()
);
if env_type == CodeGraphEnv::Git && has_version_changed(&project_path) {
log::info!("CodeGraph: version changed, performing sync before starting watcher");
if let Err(e) = manager.sync().await {
log::warn!("CodeGraph version sync failed: {}", e);
}
update_version_after_sync(&project_path);
}
log::info!("CodeGraph: performing initial sync on startup");
if let Err(e) = manager.sync().await {
log::warn!("CodeGraph initial sync failed: {}", e);
}
update_version_after_sync(&project_path);
let (change_tx, mut change_rx) = mpsc::channel::<PathBuf>(100);
let watcher_result = Self::create_file_watcher(&project_path, change_tx.clone());
if watcher_result.is_err() {
log::warn!("CodeGraph notify watcher failed to start: {}", watcher_result.err().unwrap());
release_watcher_lock(&project_path);
return;
}
let _watcher = watcher_result.unwrap();
let ignore_matcher = IgnoreMatcher::load(&project_path);
let syncing = Arc::new(AtomicBool::new(false));
let syncing_clone = syncing.clone();
let changed_files = Arc::new(RwLock::new(std::collections::HashSet::<PathBuf>::new()));
let last_change = Arc::new(std::sync::Mutex::new(Instant::now()));
let debounce_delay = Duration::from_secs(CODEGRAPH_SYNC_INTERVAL_SECS);
let git_poll_interval = Duration::from_secs(GIT_STATUS_POLL_INTERVAL_SECS);
let git_monitoring = if env_type == CodeGraphEnv::Git {
if start_git_fsmonitor(&project_path) {
log::info!("CodeGraph: Git fsmonitor daemon started");
true
} else if is_git_fsmonitor_running(&project_path) {
log::info!("CodeGraph: Git fsmonitor daemon already running");
true
} else {
log::info!("CodeGraph: Git fsmonitor not available, using git status polling");
false
}
} else {
false
};
log::info!(
"CodeGraph watcher started (Git monitoring: {}, notify fallback: always)",
git_monitoring
);
let check_interval = Duration::from_secs(1);
loop {
if cancel_token.is_cancelled() {
let pending_count = changed_files.read().await.len();
if pending_count > 0 {
log::info!(
"CodeGraph: final sync before exit ({} unique files)",
pending_count
);
let manager = CodeGraphManager::new(&project_path);
if manager.is_initialized() {
let _ = manager.sync().await;
update_version_after_sync(&project_path);
}
}
release_watcher_lock(&project_path);
log::info!("CodeGraph watcher stopped");
break;
}
update_watcher_heartbeat(&project_path);
tokio::select! {
Some(path) = change_rx.recv() => {
if cancel_token.is_cancelled() {
break;
}
if is_source_file(&path)
&& !ignore_matcher.should_ignore(&path, &project_path) {
{
let mut files = changed_files.write().await;
if files.insert(path.clone()) {
*last_change.lock().unwrap() = Instant::now();
log::debug!(
"CodeGraph [notify]: new file {} (total unique: {})",
path.display(),
files.len()
);
}
}
}
}
_ = sleep(git_poll_interval), if git_monitoring => {
if cancel_token.is_cancelled() {
break;
}
let changes = get_git_status_changes(&project_path);
if changes.has_changes() {
let mut new_count = 0;
{
let mut files = changed_files.write().await;
for path in changes.modified.iter().chain(&changes.added).chain(&changes.deleted) {
if files.insert(path.clone()) {
new_count += 1;
}
}
if new_count > 0 {
log::debug!(
"CodeGraph [git]: {} new changes (modified: {}, added: {}, deleted: {}, total unique: {})",
new_count,
changes.modified.len(),
changes.added.len(),
changes.deleted.len(),
files.len()
);
}
}
if new_count > 0 {
*last_change.lock().unwrap() = Instant::now();
}
}
}
_ = sleep(check_interval) => {
if cancel_token.is_cancelled() {
break;
}
let files_count = changed_files.read().await.len();
let elapsed = last_change.lock().unwrap().elapsed();
if !syncing_clone.load(Ordering::SeqCst)
&& files_count > 0
&& elapsed >= debounce_delay {
syncing_clone.store(true, Ordering::SeqCst);
log::info!("CodeGraph: auto-sync triggered ({} unique files changed)", files_count);
if try_acquire_sync_lock(&project_path) {
let manager = CodeGraphManager::new(&project_path);
if manager.is_initialized() {
if let Err(e) = manager.sync().await {
log::warn!("CodeGraph sync failed: {}", e);
} else {
update_version_after_sync(&project_path);
}
changed_files.write().await.clear();
}
release_sync_lock(&project_path);
} else {
log::debug!("CodeGraph: skipping sync, another instance is syncing");
}
syncing_clone.store(false, Ordering::SeqCst);
}
}
}
}
}
fn create_file_watcher(
project_path: &Path,
change_tx: mpsc::Sender<PathBuf>,
) -> Result<RecommendedWatcher> {
let tx = change_tx.clone();
let handler = move |event: Result<Event, notify::Error>| {
if let Ok(event) = event {
if !event.kind.is_access() && !event.kind.is_other() {
for path in event.paths {
let _ = tx.try_send(path);
}
}
}
};
let config = Config::default()
.with_poll_interval(Duration::from_secs(2)) .with_compare_contents(false);
let mut watcher = RecommendedWatcher::new(handler, config)?;
watcher.watch(project_path, RecursiveMode::Recursive)?;
Ok(watcher)
}
}
pub struct CodeGraphSearchTool {
manager: Arc<CodeGraphManager>,
}
impl CodeGraphSearchTool {
pub fn new(project_path: &Path) -> Self {
Self {
manager: Arc::new(CodeGraphManager::new(project_path)),
}
}
}
#[async_trait]
impl Tool for CodeGraphSearchTool {
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "code_search".to_string(),
description: "[优先工具] 搜索代码符号(函数、类、方法、变量)。
适用场景:
- 找函数定义(如 'handle_request'、'User::new')
- 查类定义、结构体、接口
- 定位变量声明、常量定义
- 查找方法签名、文档注释
不适用场景:
- ❌ 搜错误信息 → 用 grep(如 'failed to'、'panic')
- ❌ 搜注释内容 → 用 grep(如 'TODO')
- ❌ 搜字符串常量 → 用 grep
优先级:[高] 比grep快10-100倍,语义搜索首选".to_string(),
parameters: json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "符号名称搜索模式(支持模糊匹配)"
},
"limit": {
"type": "integer",
"description": "返回结果数量限制(默认 20)",
"default": 20
}
},
"required": ["pattern"]
}),
is_priority: true,
}
}
async fn execute(&self, params: Value) -> Result<String> {
if !self.manager.is_initialized() {
return Ok("CodeGraph 未初始化。请先运行 codegraph init -i 来构建索引。".to_string());
}
let pattern = params["pattern"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'pattern'"))?;
let limit = params["limit"].as_u64().unwrap_or(20) as usize;
let nodes = self.manager.search(pattern, limit)?;
if nodes.is_empty() {
return Ok(format!("未找到匹配 '{}' 的符号。", pattern));
}
let mut results = Vec::new();
for node in nodes {
let sig = node.signature.as_deref().unwrap_or("");
results.push(format!(
"{} {} ({})\n {}:{}\n {}",
node.kind, node.name, node.language,
node.file_path, node.start_line,
sig
));
}
Ok(results.join("\n\n"))
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::Safe
}
}
pub struct CodeGraphCallersTool {
manager: Arc<CodeGraphManager>,
}
impl CodeGraphCallersTool {
pub fn new(project_path: &Path) -> Self {
Self {
manager: Arc::new(CodeGraphManager::new(project_path)),
}
}
}
#[async_trait]
impl Tool for CodeGraphCallersTool {
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "code_callers".to_string(),
description: "[优先工具] 查找调用指定符号的所有函数/方法(向上追溯)。
适用场景:
- 查谁调用了 'handle_error'?
- 哪些地方使用了 'User::new'?
- 分析函数被哪些模块引用
不适用场景:
- ❌ 查某函数调用了谁 → 用 code_callees
- ❌ 搜字符串内容 → 用 grep
优先级:[高] 分析调用关系首选".to_string(),
parameters: json!({
"type": "object",
"properties": {
"symbol": {
"type": "string",
"description": "符号 ID 或名称"
},
"limit": {
"type": "integer",
"description": "返回结果数量限制(默认 10)",
"default": 10
}
},
"required": ["symbol"]
}),
is_priority: true,
}
}
async fn execute(&self, params: Value) -> Result<String> {
if !self.manager.is_initialized() {
return Ok("CodeGraph 未初始化。请先运行 codegraph init -i 来构建索引。".to_string());
}
let symbol = params["symbol"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'symbol'"))?;
let limit = params["limit"].as_u64().unwrap_or(10) as usize;
let symbol_id = if symbol.contains(":") {
symbol.to_string()
} else {
let nodes = self.manager.search(symbol, 1)?;
if nodes.is_empty() {
return Ok(format!("未找到符号 '{}'。", symbol));
}
nodes[0].id.clone()
};
let callers = self.manager.callers(&symbol_id, limit)?;
if callers.is_empty() {
return Ok(format!("符号 '{}' 没有调用者。", symbol));
}
let mut results = Vec::new();
for node in callers {
results.push(format!(
"{} {} ({})\n {}:{}",
node.kind, node.name, node.language,
node.file_path, node.start_line
));
}
Ok(format!("调用 '{}' 的符号:\n\n{}", symbol, results.join("\n")))
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::Safe
}
}
pub struct CodeGraphCalleesTool {
manager: Arc<CodeGraphManager>,
}
impl CodeGraphCalleesTool {
pub fn new(project_path: &Path) -> Self {
Self {
manager: Arc::new(CodeGraphManager::new(project_path)),
}
}
}
#[async_trait]
impl Tool for CodeGraphCalleesTool {
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "code_callees".to_string(),
description: "[优先工具] 查找指定符号调用的所有函数/方法(向下追踪)。
适用场景:
- 查 'handle_request' 调用了哪些函数?
- 分析函数内部的执行流程
- 追踪代码依赖关系
不适用场景:
- ❌ 查谁调用了某函数 → 用 code_callers
- ❌ 搜字符串内容 → 用 grep
优先级:[高] 分析执行流程首选".to_string(),
parameters: json!({
"type": "object",
"properties": {
"symbol": {
"type": "string",
"description": "符号 ID 或名称"
},
"limit": {
"type": "integer",
"description": "返回结果数量限制(默认 10)",
"default": 10
}
},
"required": ["symbol"]
}),
is_priority: true,
}
}
async fn execute(&self, params: Value) -> Result<String> {
if !self.manager.is_initialized() {
return Ok("CodeGraph 未初始化。请先运行 codegraph init -i 来构建索引。".to_string());
}
let symbol = params["symbol"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'symbol'"))?;
let limit = params["limit"].as_u64().unwrap_or(10) as usize;
let symbol_id = if symbol.contains(":") {
symbol.to_string()
} else {
let nodes = self.manager.search(symbol, 1)?;
if nodes.is_empty() {
return Ok(format!("未找到符号 '{}'。", symbol));
}
nodes[0].id.clone()
};
let callees = self.manager.callees(&symbol_id, limit)?;
if callees.is_empty() {
return Ok(format!("符号 '{}' 不调用其他符号。", symbol));
}
let mut results = Vec::new();
for node in callees {
results.push(format!(
"{} {} ({})\n {}:{}",
node.kind, node.name, node.language,
node.file_path, node.start_line
));
}
Ok(format!("'{}' 调用的符号:\n\n{}", symbol, results.join("\n")))
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::Safe
}
}
pub struct CodeGraphStatusTool {
manager: Arc<CodeGraphManager>,
}
impl CodeGraphStatusTool {
pub fn new(project_path: &Path) -> Self {
Self {
manager: Arc::new(CodeGraphManager::new(project_path)),
}
}
}
#[async_trait]
impl Tool for CodeGraphStatusTool {
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "code_status".to_string(),
description: "检查 CodeGraph 索引状态。返回文件数、节点数、边数、支持的语言等信息。".to_string(),
parameters: json!({
"type": "object",
"properties": {}
}),
is_priority: false,
}
}
async fn execute(&self, _params: Value) -> Result<String> {
if get_codegraph_path().is_none() {
return Ok("CodeGraph CLI 未安装。\n\n请先安装 CodeGraph CLI:\n- Windows: 运行 PowerShell 安装脚本\n- Linux/Mac: 运行安装脚本\n\n安装后运行 'codegraph init -i' 来构建代码索引。".to_string());
}
let status = self.manager.status()?;
if !status.initialized {
return Ok("CodeGraph 未初始化。\n\n运行 'codegraph init -i' 来构建代码索引,或在 matrixcode 中使用 /init 命令。".to_string());
}
Ok(format!(
"CodeGraph 状态:\n\n文件数: {}\n节点数: {}\n边数: {}\n语言: {}",
status.file_count,
status.node_count,
status.edge_count,
status.languages.join(", ")
))
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::Safe
}
}
pub struct CodeGraphSyncTool {
manager: Arc<CodeGraphManager>,
}
impl CodeGraphSyncTool {
pub fn new(project_path: &Path) -> Self {
Self {
manager: Arc::new(CodeGraphManager::new(project_path)),
}
}
}
#[async_trait]
impl Tool for CodeGraphSyncTool {
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "code_sync".to_string(),
description: "手动同步 CodeGraph 索引。当代码库有变化但自动同步未触发时使用,确保搜索结果是最新的。".to_string(),
parameters: json!({
"type": "object",
"properties": {}
}),
is_priority: false,
}
}
async fn execute(&self, _params: Value) -> Result<String> {
if !self.manager.is_initialized() {
return Ok("CodeGraph 未初始化。请先运行 codegraph init -i 来构建索引。".to_string());
}
log::info!("CodeGraph: manual sync triggered by AI");
self.manager.sync().await?;
let status = self.manager.status()?;
Ok(format!(
"CodeGraph 索引已同步。\n\n文件数: {}\n节点数: {}\n边数: {}\n语言: {}",
status.file_count,
status.node_count,
status.edge_count,
status.languages.join(", ")
))
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::Safe
}
}
pub fn codegraph_tools(project_path: &Path) -> Vec<Box<dyn Tool>> {
vec![
Box::new(CodeGraphSearchTool::new(project_path)),
Box::new(CodeGraphCallersTool::new(project_path)),
Box::new(CodeGraphCalleesTool::new(project_path)),
Box::new(CodeGraphStatusTool::new(project_path)),
Box::new(CodeGraphSyncTool::new(project_path)),
]
}
pub fn codegraph_tools_with_auto_detect(start_path: &Path) -> Vec<Box<dyn Tool>> {
let project_root = find_project_root(start_path);
codegraph_tools(&project_root)
}
pub fn should_inject_codegraph_tools(start_path: &Path) -> bool {
if get_codegraph_path().is_none() {
return false;
}
let project_root = find_project_root(start_path);
project_root.join(".codegraph").join("codegraph.db").exists()
}
pub fn codegraph_tools_if_installed(start_path: &Path) -> Vec<Box<dyn Tool>> {
if should_inject_codegraph_tools(start_path) {
codegraph_tools_with_auto_detect(start_path)
} else {
vec![]
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_codegraph_manager_creation() {
let path = PathBuf::from(".");
let manager = CodeGraphManager::new(&path);
assert!(manager.db_path.to_str().unwrap().contains(".codegraph"));
}
#[test]
fn test_tool_definitions() {
let path = PathBuf::from(".");
let tools = codegraph_tools(&path);
let names: Vec<String> = tools.iter().map(|t| t.definition().name).collect();
assert!(names.contains(&"code_search".to_string()));
assert!(names.contains(&"code_callers".to_string()));
assert!(names.contains(&"code_callees".to_string()));
assert!(names.contains(&"code_status".to_string()));
assert!(names.contains(&"code_sync".to_string()));
}
#[test]
fn test_search_tool_priority() {
let path = PathBuf::from(".");
let tools = codegraph_tools(&path);
for tool in tools {
let def = tool.definition();
if def.name == "code_search" {
assert!(def.is_priority);
}
}
}
#[test]
fn test_find_project_root_current_dir() {
let start_path = PathBuf::from(".");
let root = find_project_root(&start_path);
assert!(root.join(".git").exists() || root.join("Cargo.toml").exists());
}
#[test]
fn test_find_project_root_subdirectory() {
let start_path = PathBuf::from("./src");
let root = find_project_root(&start_path);
assert!(root.join(".git").exists() || root.join("Cargo.toml").exists());
}
#[test]
fn test_manager_with_auto_detect() {
let start_path = PathBuf::from(".");
let manager = CodeGraphManager::with_auto_detect(&start_path);
assert!(manager.project_path.join(".git").exists()
|| manager.project_path.join("Cargo.toml").exists()
|| manager.project_path.join("package.json").exists());
}
}