use crate::indexer::{
compute_l3_cache_batch_indices, read_batch_sources, DEFAULT_L3_CACHE_SIZE, TARGET_CACHE_USAGE,
};
use crate::manifest::detect_include_paths_from_root;
use crate::project_config::ProjectConfig;
use crate::{CodeGraph, FileSystemWatcher, WatcherConfig};
use anyhow::{Context, Result};
use indicatif::{ProgressBar, ProgressStyle};
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct WatchPipelineConfig {
pub root_path: PathBuf,
pub db_path: PathBuf,
pub watcher_config: WatcherConfig,
pub scan_initial: bool,
pub include_patterns: Vec<String>,
pub exclude_patterns: Vec<String>,
pub compile_commands_path: Option<PathBuf>,
}
impl WatchPipelineConfig {
pub fn new(
root_path: PathBuf,
db_path: PathBuf,
watcher_config: WatcherConfig,
scan_initial: bool,
) -> Self {
Self {
root_path,
db_path,
watcher_config,
scan_initial,
include_patterns: Vec::new(),
exclude_patterns: Vec::new(),
compile_commands_path: None,
}
}
}
#[derive(Clone)]
struct PipelineSharedState {
dirty_paths: Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>,
wakeup_tx: std::sync::mpsc::SyncSender<()>,
}
impl PipelineSharedState {
fn new() -> (Self, std::sync::mpsc::Receiver<()>) {
let (wakeup_tx, wakeup_rx) = std::sync::mpsc::sync_channel(1);
(
Self {
dirty_paths: Arc::new(parking_lot::Mutex::new(BTreeSet::new())),
wakeup_tx,
},
wakeup_rx,
)
}
fn insert_dirty_paths(&self, paths: &[PathBuf]) -> Result<()> {
let mut dirty_paths = self.dirty_paths.lock();
dirty_paths.extend(paths.iter().cloned());
let _ = self.wakeup_tx.try_send(());
Ok(())
}
fn drain_dirty_paths(&self) -> Result<Vec<PathBuf>> {
let mut paths = self.dirty_paths.lock();
let snapshot: Vec<PathBuf> = std::mem::take(&mut *paths).into_iter().collect();
Ok(snapshot)
}
}
fn merge_scan_config(
scan_root: &std::path::Path,
config: &WatchPipelineConfig,
) -> Result<crate::project_config::ProjectConfig> {
let project_config = ProjectConfig::load(scan_root).context("Failed to load .magellan.toml")?;
let auto_detected = if project_config.index.include.is_empty() {
detect_include_paths_from_root(scan_root)
} else {
Vec::new()
};
let root_is_subdir = scan_root
.file_name()
.is_some_and(|n| matches!(n.to_str(), Some("src") | Some("tests") | Some("benches")));
let merged_include = if config.include_patterns.is_empty() {
if root_is_subdir {
vec![]
} else if project_config.index.include.is_empty() {
auto_detected
} else {
project_config.index.include.clone()
}
} else {
config.include_patterns.clone()
};
Ok(crate::project_config::ProjectConfig {
index: crate::project_config::IndexSection {
include: merged_include,
exclude: if config.exclude_patterns.is_empty() {
project_config.index.exclude.clone()
} else {
config.exclude_patterns.clone()
},
},
..project_config
})
}
fn wait_for_watcher_thread(thread: std::thread::JoinHandle<()>, timeout: Duration) {
let start = Instant::now();
while !thread.is_finished() {
if start.elapsed() >= timeout {
eprintln!(
"Warning: Watcher thread did not finish within {:?}, forcing shutdown",
timeout
);
eprintln!(
"Note: Data may not be flushed. Use Ctrl+C (not timeout) for clean shutdown."
);
return;
}
thread::sleep(Duration::from_millis(100));
}
match thread.join() {
Ok(()) => {}
Err(payload) => {
if let Some(msg) = payload.downcast_ref::<&str>() {
eprintln!("Watcher thread panicked: {}", msg);
} else if let Some(msg) = payload.downcast_ref::<String>() {
eprintln!("Watcher thread panicked: {}", msg);
} else {
eprintln!("Watcher thread panicked with unknown payload");
}
}
}
}
pub fn run_watch_pipeline(config: WatchPipelineConfig, shutdown: Arc<AtomicBool>) -> Result<usize> {
let scan_root =
std::fs::canonicalize(&config.root_path).unwrap_or_else(|_| config.root_path.clone());
let merged_config = merge_scan_config(&scan_root, &config)?;
let mut graph = CodeGraph::open(&config.db_path)?;
if let Some(ref cc_path) = config.compile_commands_path {
graph.set_compile_commands(cc_path)?;
}
if let Ok(manifest) = crate::manifest::CargoManifest::parse(&scan_root) {
if manifest.package_name.is_some() {
if let Ok(conn) = rusqlite::Connection::open(&config.db_path) {
if let Err(e) = manifest.store_in_db(&conn) {
eprintln!("Failed to store Cargo manifest metadata: {}", e);
}
}
}
}
graph.batch_mode = false;
let (shared_state, wakeup_rx) = PipelineSharedState::new();
let main_state = shared_state.clone();
let watcher_thread = {
let root_path = config.root_path.clone();
let watcher_config = config.watcher_config.clone();
let shared_state = Arc::new(shared_state);
let shutdown_watch = shutdown.clone();
thread::spawn(move || {
let result = watcher_loop(root_path, watcher_config, shared_state, shutdown_watch);
crate::ingest::pool::cleanup_parsers();
if let Err(e) = result {
eprintln!("Watcher thread error: {:?}", e);
}
})
};
if config.scan_initial {
use indicatif::HumanCount;
let file_filter = merged_config.to_file_filter(&scan_root)?;
graph.scan_directory_with_filter(
&scan_root,
&file_filter,
Some(&|current, total, file_path| {
static PB: std::sync::OnceLock<ProgressBar> = std::sync::OnceLock::new();
let pb = PB.get_or_init(|| {
let pb = ProgressBar::new(total as u64);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({percent}%) ETA: {eta}\n{msg}")
.expect("invariant: hardcoded ProgressStyle template string is valid")
.progress_chars("=>-"),
);
pb
});
pb.set_position(current as u64);
pb.set_message(format!("Scanning: {}", file_path));
if current >= total {
pb.finish_with_message(format!("Scanned {} files", HumanCount(total as u64)));
}
}),
)?;
if let Err(e) = graph.rebuild_fts5() {
eprintln!("Warning: FTS5 rebuild after scan failed: {}", e);
}
if let Err(e) = graph.rebuild_code_chunks_fts() {
eprintln!("Warning: code_chunks_fts rebuild after scan failed: {}", e);
}
}
let mut total_processed = 0;
let paths_during_scan = main_state.drain_dirty_paths()?;
if !paths_during_scan.is_empty() {
println!(
"Flushing {} buffered path(s) from scan...",
paths_during_scan.len()
);
total_processed += process_dirty_paths(&mut graph, &paths_during_scan)?;
if let Err(e) = graph.checkpoint_wal() {
eprintln!("Warning: WAL checkpoint failed after scan flush: {}", e);
}
if let Err(e) = verify_db_integrity(&config.db_path) {
eprintln!(
"Warning: Database integrity check failed after scan flush: {}",
e
);
}
}
println!("Magellan watching: {}", config.root_path.display());
println!("Database: {}", config.db_path.display());
while !shutdown.load(Ordering::SeqCst) {
match wakeup_rx.recv_timeout(Duration::from_millis(100)) {
Ok(()) => {
let dirty_paths = main_state.drain_dirty_paths()?;
if !dirty_paths.is_empty() {
total_processed += process_dirty_paths(&mut graph, &dirty_paths)?;
if let Err(e) = graph.checkpoint_wal() {
eprintln!("Warning: WAL checkpoint failed after watch batch: {}", e);
}
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
continue;
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
break;
}
}
}
wait_for_watcher_thread(watcher_thread, Duration::from_secs(25));
crate::ingest::pool::cleanup_parsers();
Ok(total_processed)
}
fn watcher_loop(
root_path: PathBuf,
config: WatcherConfig,
shared_state: Arc<PipelineSharedState>,
shutdown: Arc<AtomicBool>,
) -> Result<()> {
let watcher = FileSystemWatcher::new(root_path, config, shutdown.clone())?;
while !shutdown.load(Ordering::SeqCst) {
match watcher.recv_batch_timeout(Duration::from_millis(100)) {
Ok(Some(batch)) => {
shared_state.insert_dirty_paths(&batch.paths)?;
}
Ok(None) => {
break;
}
Err(_) => {
continue;
}
}
}
Ok(())
}
fn process_dirty_paths(graph: &mut CodeGraph, dirty_paths: &[PathBuf]) -> Result<usize> {
process_dirty_paths_batched(graph, dirty_paths)
}
fn process_dirty_paths_batched(graph: &mut CodeGraph, dirty_paths: &[PathBuf]) -> Result<usize> {
if dirty_paths.is_empty() {
return Ok(0);
}
let batch_start = Instant::now();
let size_start = Instant::now();
let sizes: Vec<usize> = dirty_paths
.iter()
.filter_map(|path| std::fs::metadata(path).ok().map(|meta| meta.len() as usize))
.collect();
let size_time = size_start.elapsed();
let target_cache_bytes = (DEFAULT_L3_CACHE_SIZE as f64 * TARGET_CACHE_USAGE) as usize;
let batch_start_compute = Instant::now();
let batches = compute_l3_cache_batch_indices(&sizes, target_cache_bytes);
let batch_count = batches.len();
let batch_compute_time = batch_start_compute.elapsed();
let mut total_processed = 0;
let mut total_read_time = std::time::Duration::ZERO;
let mut total_reconcile_time = std::time::Duration::ZERO;
for batch in &batches {
let batch_paths: Vec<&PathBuf> = batch.iter().map(|&idx| &dirty_paths[idx]).collect();
let read_start = Instant::now();
let sources = read_batch_sources(&batch_paths);
total_read_time += read_start.elapsed();
let source_map: std::collections::HashMap<PathBuf, Vec<u8>> = sources
.into_iter()
.map(|(path, source, _)| (path, source))
.collect();
for &path in &batch_paths {
let path_key = crate::validation::normalize_path(path)
.unwrap_or_else(|_| path.to_string_lossy().to_string());
let reconcile_start = Instant::now();
let outcome = if let Some(source) = source_map.get(path) {
graph.reconcile_file_path_with_source(path, &path_key, source)
} else {
graph.reconcile_file_path(path, &path_key)
};
match outcome {
Ok(outcome) => {
total_reconcile_time += reconcile_start.elapsed();
let path_str = path.to_string_lossy();
let was_modified = match outcome {
crate::ReconcileOutcome::Deleted => {
println!("DELETE {}", path_str);
true
}
crate::ReconcileOutcome::Unchanged => {
false
}
crate::ReconcileOutcome::Reindexed {
symbols,
references,
calls,
} => {
println!(
"MODIFY {} symbols={} refs={} calls={}",
path_str, symbols, references, calls
);
true
}
};
if was_modified {
total_processed += 1;
}
}
Err(e) => {
total_reconcile_time += reconcile_start.elapsed();
let path_str = path.to_string_lossy();
println!("ERROR {} {}", path_str, e);
}
}
}
}
for path in dirty_paths {
if !path.exists() {
let path_key = crate::validation::normalize_path(path)
.unwrap_or_else(|_| path.to_string_lossy().to_string());
if let Err(e) = graph.delete_file_facts(&path_key) {
eprintln!("Failed to delete file facts for {}: {}", path.display(), e);
}
println!("DELETE {}", path.to_string_lossy());
total_processed += 1;
}
}
let elapsed = batch_start.elapsed();
if total_processed > 0 {
eprintln!(
"L3 Batch: {} files processed, {} batches, {}ms total (size:{}ms batch:{}ms read:{}ms reconcile:{}ms)",
total_processed,
batch_count,
elapsed.as_millis(),
size_time.as_millis(),
batch_compute_time.as_millis(),
total_read_time.as_millis(),
total_reconcile_time.as_millis()
);
if let Err(e) = graph.rebuild_fts5() {
eprintln!("Warning: FTS5 rebuild failed: {}", e);
}
if let Err(e) = graph.rebuild_code_chunks_fts() {
eprintln!("Warning: code_chunks_fts rebuild failed: {}", e);
}
}
Ok(total_processed)
}
fn verify_db_integrity(db_path: &std::path::Path) -> Result<()> {
let conn = rusqlite::Connection::open(db_path)
.map_err(|e| anyhow::anyhow!("Failed to open DB for integrity check: {}", e))?;
let result: String = conn
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
.map_err(|e| anyhow::anyhow!("Integrity check query failed: {}", e))?;
if result != "ok" {
return Err(anyhow::anyhow!(
"Database integrity check failed: {}",
result
));
}
Ok(())
}