use std::path::{Path, PathBuf};
use std::time::Instant;
use anyhow::{Context, Result};
use crate::code_graph::CodeGraph;
use crate::graph::Graph;
use crate::ignore::IgnoreList;
use crate::storage::{load_graph_auto, save_graph_auto, StorageBackend};
use crate::unify::{codegraph_to_graph_nodes, merge_code_layer, generate_bridge_edges};
use crate::semantify::apply_heuristic_layers;
#[derive(Debug, Clone)]
pub struct SyncResult {
pub files_changed: usize,
pub code_nodes: usize,
pub code_edges: usize,
pub bridge_edges: usize,
pub duration_ms: u64,
pub graph_modified: bool,
}
#[derive(Debug, Clone)]
pub struct WatchConfig {
pub watch_dir: PathBuf,
pub gid_dir: PathBuf,
pub debounce_ms: u64,
pub lsp: bool,
pub no_semantify: bool,
pub backend: Option<StorageBackend>,
}
impl WatchConfig {
pub fn new(watch_dir: PathBuf, gid_dir: PathBuf) -> Self {
Self {
watch_dir,
gid_dir,
debounce_ms: 1000,
lsp: true,
no_semantify: false,
backend: None,
}
}
}
pub fn should_trigger_sync(path: &Path, watch_dir: &Path, gid_dir: &Path, ignore_list: &IgnoreList) -> bool {
if path.starts_with(gid_dir) {
return false;
}
let git_dir = watch_dir.join(".git");
if path.starts_with(&git_dir) {
return false;
}
if let Ok(rel) = path.strip_prefix(watch_dir) {
let rel_str = rel.to_string_lossy();
if ignore_list.should_ignore(&rel_str, path.is_dir()) {
return false;
}
for component in rel.components() {
let comp_str = component.as_os_str().to_string_lossy();
if ignore_list.should_ignore(&comp_str, true) {
return false;
}
}
}
match path.extension().and_then(|e| e.to_str()) {
Some("rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "java" | "c" | "cpp" | "h" | "hpp"
| "rb" | "swift" | "kt" | "scala" | "zig" | "toml" | "yaml" | "yml" | "json") => true,
Some("mod") => {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
name == "go.mod"
}
Some("gradle") => true,
None => {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
matches!(name, "Makefile" | "Dockerfile")
}
_ => false,
}
}
pub fn sync_on_change(config: &WatchConfig) -> Result<SyncResult> {
let start = Instant::now();
let meta_path = config.gid_dir.join("extract-meta.json");
let (code_graph, report) = CodeGraph::extract_incremental(
&config.watch_dir,
&config.gid_dir,
&meta_path,
false, ).context("incremental extraction failed")?;
let files_changed = report.added + report.modified + report.deleted;
if files_changed == 0 {
return Ok(SyncResult {
files_changed: 0,
code_nodes: 0,
code_edges: 0,
bridge_edges: 0,
duration_ms: start.elapsed().as_millis() as u64,
graph_modified: false,
});
}
let (code_nodes, code_edges) = codegraph_to_graph_nodes(&code_graph, &config.watch_dir);
let code_node_count = code_nodes.len();
let code_edge_count = code_edges.len();
let mut graph = load_graph_auto(&config.gid_dir, config.backend).unwrap_or_default();
merge_code_layer(&mut graph, code_nodes, code_edges);
if !config.no_semantify {
apply_heuristic_layers(&mut graph);
generate_bridge_edges(&mut graph);
}
let bridge_count = graph.bridge_edges().len();
save_graph_auto(&graph, &config.gid_dir, config.backend)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(SyncResult {
files_changed,
code_nodes: code_node_count,
code_edges: code_edge_count,
bridge_edges: bridge_count,
duration_ms: start.elapsed().as_millis() as u64,
graph_modified: true,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
use crate::parser::load_graph;
fn setup_test_project(source: &str) -> (TempDir, PathBuf, PathBuf) {
let tmp = TempDir::new().unwrap();
let src_dir = tmp.path().join("src");
fs::create_dir_all(&src_dir).unwrap();
let gid_dir = tmp.path().join(".gid");
fs::create_dir_all(&gid_dir).unwrap();
fs::write(src_dir.join("main.rs"), source).unwrap();
fs::write(gid_dir.join("graph.yml"), "nodes: []\nedges: []\n").unwrap();
(tmp, src_dir, gid_dir)
}
#[test]
fn test_trigger_rust_file() {
let ignore = IgnoreList::with_defaults();
let watch = Path::new("/project");
let gid = Path::new("/project/.gid");
assert!(should_trigger_sync(Path::new("/project/src/main.rs"), watch, gid, &ignore));
}
#[test]
fn test_trigger_python_file() {
let ignore = IgnoreList::with_defaults();
let watch = Path::new("/project");
let gid = Path::new("/project/.gid");
assert!(should_trigger_sync(Path::new("/project/lib/parser.py"), watch, gid, &ignore));
}
#[test]
fn test_trigger_typescript_file() {
let ignore = IgnoreList::with_defaults();
let watch = Path::new("/project");
let gid = Path::new("/project/.gid");
assert!(should_trigger_sync(Path::new("/project/src/app.tsx"), watch, gid, &ignore));
}
#[test]
fn test_no_trigger_gid_dir() {
let ignore = IgnoreList::with_defaults();
let watch = Path::new("/project");
let gid = Path::new("/project/.gid");
assert!(!should_trigger_sync(Path::new("/project/.gid/graph.yml"), watch, gid, &ignore));
}
#[test]
fn test_no_trigger_git_dir() {
let ignore = IgnoreList::with_defaults();
let watch = Path::new("/project");
let gid = Path::new("/project/.gid");
assert!(!should_trigger_sync(Path::new("/project/.git/HEAD"), watch, gid, &ignore));
}
#[test]
fn test_no_trigger_binary_file() {
let ignore = IgnoreList::with_defaults();
let watch = Path::new("/project");
let gid = Path::new("/project/.gid");
assert!(!should_trigger_sync(Path::new("/project/image.png"), watch, gid, &ignore));
}
#[test]
fn test_no_trigger_compiled_file() {
let ignore = IgnoreList::with_defaults();
let watch = Path::new("/project");
let gid = Path::new("/project/.gid");
assert!(!should_trigger_sync(Path::new("/project/main.o"), watch, gid, &ignore));
}
#[test]
fn test_no_trigger_node_modules() {
let ignore = IgnoreList::with_defaults();
let watch = Path::new("/project");
let gid = Path::new("/project/.gid");
assert!(!should_trigger_sync(
Path::new("/project/node_modules/lodash/index.js"), watch, gid, &ignore
));
}
#[test]
fn test_no_trigger_target_dir() {
let ignore = IgnoreList::with_defaults();
let watch = Path::new("/project");
let gid = Path::new("/project/.gid");
assert!(!should_trigger_sync(
Path::new("/project/target/debug/main.rs"), watch, gid, &ignore
));
}
#[test]
fn test_trigger_cargo_toml() {
let ignore = IgnoreList::with_defaults();
let watch = Path::new("/project");
let gid = Path::new("/project/.gid");
assert!(should_trigger_sync(Path::new("/project/Cargo.toml"), watch, gid, &ignore));
}
#[test]
fn test_trigger_json_config() {
let ignore = IgnoreList::with_defaults();
let watch = Path::new("/project");
let gid = Path::new("/project/.gid");
assert!(should_trigger_sync(Path::new("/project/tsconfig.json"), watch, gid, &ignore));
}
#[test]
fn test_trigger_go_file() {
let ignore = IgnoreList::with_defaults();
let watch = Path::new("/project");
let gid = Path::new("/project/.gid");
assert!(should_trigger_sync(Path::new("/project/cmd/main.go"), watch, gid, &ignore));
}
#[test]
fn test_no_trigger_markdown() {
let ignore = IgnoreList::with_defaults();
let watch = Path::new("/project");
let gid = Path::new("/project/.gid");
assert!(!should_trigger_sync(Path::new("/project/README.md"), watch, gid, &ignore));
}
#[test]
fn test_trigger_makefile() {
let ignore = IgnoreList::with_defaults();
let watch = Path::new("/project");
let gid = Path::new("/project/.gid");
assert!(should_trigger_sync(Path::new("/project/Makefile"), watch, gid, &ignore));
}
#[test]
fn test_trigger_dockerfile() {
let ignore = IgnoreList::with_defaults();
let watch = Path::new("/project");
let gid = Path::new("/project/.gid");
assert!(should_trigger_sync(Path::new("/project/Dockerfile"), watch, gid, &ignore));
}
#[test]
fn test_no_trigger_lock_file() {
let ignore = IgnoreList::with_defaults();
let watch = Path::new("/project");
let gid = Path::new("/project/.gid");
assert!(!should_trigger_sync(Path::new("/project/Cargo.lock"), watch, gid, &ignore));
}
#[test]
fn test_custom_gidignore_pattern() {
let mut ignore = IgnoreList::with_defaults();
ignore.add("generated/").unwrap();
let watch = Path::new("/project");
let gid = Path::new("/project/.gid");
assert!(!should_trigger_sync(
Path::new("/project/generated/types.rs"), watch, gid, &ignore
));
}
#[test]
fn test_sync_creates_graph_from_source() {
let (_tmp, _src_dir, gid_dir) = setup_test_project(
r#"
pub fn hello() -> String {
"hello".to_string()
}
pub fn world() -> String {
"world".to_string()
}
"#,
);
let config = WatchConfig::new(
_tmp.path().to_path_buf(),
gid_dir.clone(),
);
let result = sync_on_change(&config).unwrap();
assert!(result.graph_modified, "files_changed={} code_nodes={}", result.files_changed, result.code_nodes);
assert!(result.files_changed > 0);
assert!(result.code_nodes > 0);
assert!(result.duration_ms < 30_000);
let graph = load_graph(&gid_dir.join("graph.yml")).unwrap();
assert!(!graph.nodes.is_empty());
}
#[test]
fn test_sync_no_change_second_run() {
let (_tmp, _src_dir, gid_dir) = setup_test_project(
"pub fn stable() {}\n",
);
let config = WatchConfig::new(
_tmp.path().to_path_buf(),
gid_dir.clone(),
);
let r1 = sync_on_change(&config).unwrap();
assert!(r1.graph_modified);
let r2 = sync_on_change(&config).unwrap();
assert!(!r2.graph_modified);
assert_eq!(r2.files_changed, 0);
}
#[test]
fn test_sync_detects_file_modification() {
let (_tmp, src_dir, gid_dir) = setup_test_project(
"pub fn original() {}\n",
);
let config = WatchConfig::new(
_tmp.path().to_path_buf(),
gid_dir.clone(),
);
let r1 = sync_on_change(&config).unwrap();
assert!(r1.graph_modified);
std::thread::sleep(std::time::Duration::from_millis(100));
fs::write(src_dir.join("main.rs"), "pub fn modified() {}\npub fn added() {}\n").unwrap();
let r2 = sync_on_change(&config).unwrap();
assert!(r2.graph_modified);
assert!(r2.files_changed > 0);
}
#[test]
fn test_sync_preserves_project_nodes() {
let (_tmp, _src_dir, gid_dir) = setup_test_project(
"pub fn code() {}\n",
);
let graph_content = r#"
nodes:
- id: task-auth
title: "Implement auth"
type: task
status: todo
edges: []
"#;
fs::write(gid_dir.join("graph.yml"), graph_content).unwrap();
let config = WatchConfig::new(
_tmp.path().to_path_buf(),
gid_dir.clone(),
);
let result = sync_on_change(&config).unwrap();
assert!(result.graph_modified);
let graph = load_graph(&gid_dir.join("graph.yml")).unwrap();
assert!(graph.get_node("task-auth").is_some(), "project node should be preserved");
}
#[test]
fn test_sync_atomic_write() {
let (_tmp, _src_dir, gid_dir) = setup_test_project(
"pub fn atomic() {}\n",
);
let config = WatchConfig::new(
_tmp.path().to_path_buf(),
gid_dir.clone(),
);
sync_on_change(&config).unwrap();
assert!(!gid_dir.join("graph.yml.tmp").exists());
let graph = load_graph(&gid_dir.join("graph.yml")).unwrap();
assert!(!graph.nodes.is_empty());
}
#[test]
fn test_sync_with_no_semantify() {
let (_tmp, _src_dir, gid_dir) = setup_test_project(
"pub fn no_sem() {}\n",
);
let mut config = WatchConfig::new(
_tmp.path().to_path_buf(),
gid_dir.clone(),
);
config.no_semantify = true;
let result = sync_on_change(&config).unwrap();
assert!(result.graph_modified);
assert_eq!(result.bridge_edges, 0);
}
#[test]
fn test_sync_result_fields() {
let (_tmp, _src_dir, gid_dir) = setup_test_project(
"pub fn field_check() {}\n",
);
let config = WatchConfig::new(
_tmp.path().to_path_buf(),
gid_dir,
);
let result = sync_on_change(&config).unwrap();
assert!(result.graph_modified);
assert!(result.files_changed > 0);
assert!(result.code_nodes > 0);
assert!(result.duration_ms < 60_000);
}
#[test]
fn test_sync_new_file_added() {
let (_tmp, src_dir, gid_dir) = setup_test_project(
"pub fn initial() {}\n",
);
let config = WatchConfig::new(
_tmp.path().to_path_buf(),
gid_dir.clone(),
);
sync_on_change(&config).unwrap();
std::thread::sleep(std::time::Duration::from_millis(100));
fs::write(src_dir.join("utils.rs"), "pub fn helper() -> i32 { 42 }\n").unwrap();
let result = sync_on_change(&config).unwrap();
assert!(result.graph_modified);
let graph = load_graph(&gid_dir.join("graph.yml")).unwrap();
let func_nodes: Vec<_> = graph.nodes.iter()
.filter(|n| n.node_kind.as_deref() == Some("Function"))
.collect();
assert!(func_nodes.len() >= 2, "should have at least 2 function nodes, got {}", func_nodes.len());
}
#[test]
fn test_sync_missing_gid_dir() {
let tmp = TempDir::new().unwrap();
let src_dir = tmp.path().join("src");
fs::create_dir_all(&src_dir).unwrap();
fs::write(src_dir.join("main.rs"), "fn main() {}\n").unwrap();
let gid_dir = tmp.path().join(".gid");
fs::create_dir_all(&gid_dir).unwrap();
fs::write(gid_dir.join("graph.yml"), "nodes: []\nedges: []\n").unwrap();
let config = WatchConfig::new(
tmp.path().to_path_buf(),
gid_dir,
);
let result = sync_on_change(&config).unwrap();
assert!(result.graph_modified);
}
}