pub mod async_io;
pub mod watch;
pub use watch::{run_watch_pipeline, WatchPipelineConfig};
pub(crate) const DEFAULT_L3_CACHE_SIZE: usize = 16 * 1024 * 1024; pub(crate) const TARGET_CACHE_USAGE: f64 = 0.50; const _AVG_SOURCE_FILE_SIZE: usize = 50 * 1024;
#[derive(Debug)]
pub struct FileBatchResult {
pub symbols: usize,
pub references: usize,
pub calls: usize,
pub bytes_processed: usize,
}
pub fn compute_l3_cache_batch_indices(
sizes: &[usize],
target_cache_bytes: usize,
) -> Vec<Vec<usize>> {
if sizes.is_empty() {
return Vec::new();
}
let mut batches: Vec<Vec<usize>> = Vec::new();
let mut current_batch: Vec<usize> = Vec::new();
let mut current_batch_size: usize = 0;
for (idx, size) in sizes.iter().enumerate() {
if current_batch.is_empty() || current_batch_size + size <= target_cache_bytes {
current_batch.push(idx);
current_batch_size += size;
} else {
batches.push(std::mem::take(&mut current_batch));
current_batch.push(idx);
current_batch_size = *size;
}
}
if !current_batch.is_empty() {
batches.push(current_batch);
}
batches
}
pub fn read_batch_sources<P: AsRef<std::path::Path>>(
paths: &[P],
) -> Vec<(PathBuf, Vec<u8>, usize)> {
paths
.iter()
.filter_map(|path| {
let path = path.as_ref();
std::fs::read(path).ok().map(|source| {
let len = source.len();
(path.to_path_buf(), source, len)
})
})
.collect()
}
#[cfg(feature = "debug-prints")]
macro_rules! debug_print {
($($arg:tt)*) => {
{ eprintln!($($arg)*); }
};
}
#[cfg(not(feature = "debug-prints"))]
#[allow(
unused_macros,
reason = "noop stub: only used when feature debug-prints is enabled"
)]
macro_rules! debug_print {
($($arg:tt)*) => {
{
#[allow(clippy::unused_unit)]
()
}
};
}
use anyhow::Result;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use crate::{CodeGraph, FileEvent, FileSystemWatcher, WatcherConfig};
fn reconcile_deleted_files(graph: &mut CodeGraph, root_path: &std::path::Path) -> Result<()> {
let file_nodes = graph.all_file_nodes()?;
for (path, _file_node) in file_nodes {
let file_path = std::path::Path::new(&path);
if file_path.starts_with(root_path) && !file_path.exists() {
graph.delete_file(&path)?;
}
}
Ok(())
}
fn handle_event(graph: &mut CodeGraph, event: FileEvent) -> Result<()> {
let path_key = crate::validation::normalize_path(&event.path)
.unwrap_or_else(|_| event.path.to_string_lossy().to_string());
let _outcome = graph.reconcile_file_path(&event.path, &path_key)?;
Ok(())
}
pub fn run_indexer(root_path: PathBuf, db_path: PathBuf) -> Result<()> {
run_indexer_n(root_path, db_path, usize::MAX)?;
Ok(())
}
pub fn run_indexer_n(root_path: PathBuf, db_path: PathBuf, max_events: usize) -> Result<usize> {
let shutdown = Arc::new(AtomicBool::new(false));
let watcher = FileSystemWatcher::new(
root_path.clone(),
WatcherConfig::default(),
shutdown.clone(),
)?;
let mut graph = CodeGraph::open(&db_path)?;
reconcile_deleted_files(&mut graph, &root_path)?;
let mut processed = 0;
let mut idle_for = std::time::Duration::from_secs(0);
let idle_step = std::time::Duration::from_millis(10);
let idle_timeout_ms = std::env::var("MAGELLAN_WATCH_TIMEOUT_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(5000);
let idle_timeout = std::time::Duration::from_millis(idle_timeout_ms);
while processed < max_events {
match watcher.try_recv_event() {
Ok(Some(event)) => {
handle_event(&mut graph, event)?;
processed += 1;
idle_for = std::time::Duration::from_secs(0);
continue;
}
Ok(None) => {}
Err(e) => {
return Err(e.context("watcher mutex poisoned during event recv"));
}
}
if idle_for >= idle_timeout {
break;
}
std::thread::sleep(idle_step);
idle_for += idle_step;
}
shutdown.store(true, Ordering::SeqCst);
Ok(processed)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_compute_l3_cache_batches_empty() {
let sizes: Vec<usize> = Vec::new();
let batches = compute_l3_cache_batch_indices(&sizes, 1024);
assert!(batches.is_empty());
}
#[test]
fn test_compute_l3_cache_batches_single_file() {
let sizes = vec![100usize];
let batches = compute_l3_cache_batch_indices(&sizes, 1024);
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].len(), 1);
assert_eq!(batches[0][0], 0);
}
#[test]
fn test_compute_l3_cache_batches_fits_in_one_batch() {
let sizes = vec![100usize, 200, 300];
let batches = compute_l3_cache_batch_indices(&sizes, 1000);
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].len(), 3);
}
#[test]
fn test_compute_l3_cache_batches_splits_on_limit() {
let sizes = vec![500usize, 600, 300];
let batches = compute_l3_cache_batch_indices(&sizes, 1000);
assert_eq!(batches.len(), 2);
assert_eq!(batches[0].len(), 1); assert_eq!(batches[1].len(), 2); }
#[test]
fn test_compute_l3_cache_batches_large_file() {
let sizes = vec![100usize, 2000, 50];
let batches = compute_l3_cache_batch_indices(&sizes, 1000);
assert_eq!(batches.len(), 3);
assert_eq!(batches[0].len(), 1); assert_eq!(batches[1].len(), 1); assert_eq!(batches[2].len(), 1); }
#[test]
fn test_read_batch_sources_missing_files() {
let paths = vec![PathBuf::from("/nonexistent/path/file.rs")];
let sources = read_batch_sources(&paths);
assert!(sources.is_empty());
}
#[test]
fn test_read_batch_sources_existing_files() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let file1 = temp_dir.path().join("test1.rs");
let file2 = temp_dir.path().join("test2.rs");
fs::write(&file1, "fn main() {}").unwrap();
fs::write(&file2, "fn other() {}").unwrap();
let paths = vec![file1.clone(), file2.clone()];
let sources = read_batch_sources(&paths);
assert_eq!(sources.len(), 2);
let found: std::collections::HashSet<_> =
sources.iter().map(|(p, _, _)| p.clone()).collect();
assert!(found.contains(&file1));
assert!(found.contains(&file2));
}
#[test]
fn test_l3_cache_size_constants() {
assert_eq!(DEFAULT_L3_CACHE_SIZE, 16 * 1024 * 1024); assert!((TARGET_CACHE_USAGE - 0.50).abs() < 0.001); assert_eq!(_AVG_SOURCE_FILE_SIZE, 50 * 1024); }
#[test]
fn test_reconcile_with_source_uses_provided_bytes() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let db_path = dir.path().join("test.db");
let mut graph = crate::CodeGraph::open(&db_path).unwrap();
let source = b"fn main() { let x = 1; }";
let path = dir.path().join("test.rs");
std::fs::write(&path, source).unwrap();
let outcome = graph
.reconcile_file_path_with_source(&path, "test.rs", source)
.unwrap();
match outcome {
crate::ReconcileOutcome::Reindexed { symbols, .. } => {
assert!(symbols > 0, "Should index symbols from provided source");
}
other => panic!("Expected Reindexed, got {:?}", other),
}
}
#[test]
#[ignore = "slow integration test: run with --ignored or --include-ignored"]
fn test_watch_scan_initial_db_integrity() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let db_path = dir.path().join("watch-repro.db");
let magellan_src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
if !magellan_src.exists() {
eprintln!("Skipping: magellan src/ not found");
return;
}
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_signal = shutdown.clone();
let config = WatchPipelineConfig::new(
magellan_src.clone(),
db_path.clone(),
crate::watcher::WatcherConfig {
root_path: magellan_src.clone(),
debounce_ms: 50, gitignore_aware: true,
},
true, );
let handle = std::thread::spawn(move || run_watch_pipeline(config, shutdown));
std::thread::sleep(std::time::Duration::from_secs(4));
shutdown_signal.store(true, std::sync::atomic::Ordering::SeqCst);
let result = handle.join().expect("watch pipeline thread panicked");
assert!(result.is_ok(), "watch pipeline failed: {:?}", result.err());
let conn = rusqlite::Connection::open(&db_path).unwrap();
let integrity: String = conn
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
.unwrap();
assert_eq!(integrity, "ok", "DB integrity check failed: {}", integrity);
let file_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM graph_entities WHERE kind = 'File'",
[],
|row| row.get(0),
)
.unwrap();
assert!(
file_count > 50,
"Expected >50 file entities after scanning magellan src/, got {}",
file_count
);
let impl_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM graph_edges WHERE edge_type = 'IMPLEMENTS'",
[],
|row| row.get(0),
)
.unwrap();
assert!(
impl_count >= 1,
"Expected >=1 IMPLEMENTS edges, got {}",
impl_count
);
let distinct_count: i64 = conn
.query_row(
"SELECT COUNT(DISTINCT file_path) FROM graph_entities WHERE kind = 'File'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(
file_count, distinct_count,
"Duplicate file paths detected: {} total vs {} distinct",
file_count, distinct_count
);
}
#[test]
#[ignore = "slow integration test: run with --ignored or --include-ignored"]
fn test_watch_scan_initial_stress() {
use tempfile::tempdir;
let magellan_src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
if !magellan_src.exists() {
eprintln!("Skipping: magellan src/ not found");
return;
}
for i in 0..5 {
let dir = tempdir().unwrap();
let db_path = dir.path().join(format!("watch-stress-{}.db", i));
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_signal = shutdown.clone();
let src_clone = magellan_src.clone();
let config = WatchPipelineConfig::new(
src_clone,
db_path.clone(),
crate::watcher::WatcherConfig {
root_path: magellan_src.clone(),
debounce_ms: 50,
gitignore_aware: true,
},
true,
);
let handle = std::thread::spawn(move || run_watch_pipeline(config, shutdown));
std::thread::sleep(std::time::Duration::from_secs(4));
shutdown_signal.store(true, std::sync::atomic::Ordering::SeqCst);
let result = handle.join().expect("watch pipeline thread panicked");
assert!(
result.is_ok(),
"Stress run {} failed: {:?}",
i,
result.err()
);
let conn = rusqlite::Connection::open(&db_path).unwrap();
let integrity: String = conn
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
.unwrap();
assert_eq!(
integrity, "ok",
"Stress run {} DB integrity failed: {}",
i, integrity
);
}
}
#[test]
fn test_watch_pipeline_config_basic() {
let temp = tempfile::tempdir().unwrap();
let db_path = temp.path().join("test.db");
let config = WatchPipelineConfig::new(
temp.path().to_path_buf(),
db_path.clone(),
WatcherConfig::default(),
false,
);
assert_eq!(config.db_path, db_path);
assert_eq!(config.root_path, temp.path().to_path_buf());
}
}