use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use super::builder_v2::{build_project_call_graph_v2, BuildConfig, BuildError};
use super::cross_file_types::CallType;
fn create_python_project() -> TempDir {
let dir = TempDir::new().unwrap();
let main_py = r#"
from helper import process
def main():
process()
if __name__ == "__main__":
main()
"#;
fs::write(dir.path().join("main.py"), main_py).unwrap();
let helper_py = r#"
def process():
print("processing")
"#;
fs::write(dir.path().join("helper.py"), helper_py).unwrap();
dir
}
fn create_intra_file_project() -> TempDir {
let dir = TempDir::new().unwrap();
let code = r#"
def foo():
bar()
def bar():
baz()
def baz():
pass
"#;
fs::write(dir.path().join("module.py"), code).unwrap();
dir
}
fn create_method_call_project() -> TempDir {
let dir = TempDir::new().unwrap();
let models = r#"
class User:
def save(self):
pass
def delete(self):
pass
"#;
fs::write(dir.path().join("models.py"), models).unwrap();
let service = r#"
from models import User
def create_user():
user = User()
user.save()
def remove_user(user: User):
user.delete()
"#;
fs::write(dir.path().join("service.py"), service).unwrap();
dir
}
fn create_large_project(num_files: usize) -> TempDir {
let dir = TempDir::new().unwrap();
for i in 0..num_files {
let code = format!(
r#"
def func_{i}():
pass
def caller_{i}():
func_{i}()
"#,
i = i
);
fs::write(dir.path().join(format!("module_{}.py", i)), code).unwrap();
}
dir
}
fn create_symlink_project() -> TempDir {
let dir = TempDir::new().unwrap();
let subdir = dir.path().join("subdir");
fs::create_dir(&subdir).unwrap();
let code = "def foo(): pass";
fs::write(subdir.join("module.py"), code).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
let _ = symlink(dir.path(), subdir.join("parent_link"));
}
dir
}
fn create_non_utf8_project() -> TempDir {
let dir = TempDir::new().unwrap();
let latin1_bytes: Vec<u8> = vec![
0x64, 0x65, 0x66, 0x20, 0x66, 0x6f, 0x6f, 0x28, 0x29, 0x3a, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x70, 0x61, 0x73, 0x73, 0x0a, 0x0a, 0x23, 0x20, 0xe9, 0xe8, 0xe0, ];
fs::write(dir.path().join("legacy.py"), latin1_bytes).unwrap();
dir
}
mod main_entry_point {
use super::*;
#[test]
fn test_build_empty_project() {
let dir = TempDir::new().unwrap();
let config = BuildConfig {
language: "python".to_string(),
..Default::default()
};
let result = build_project_call_graph_v2(dir.path(), config);
assert!(result.is_ok(), "Empty project should succeed");
let ir = result.unwrap();
assert_eq!(ir.file_count(), 0, "Empty project should have 0 files");
assert_eq!(
ir.function_count(),
0,
"Empty project should have 0 functions"
);
}
#[test]
fn test_build_single_file() {
let dir = create_intra_file_project();
let config = BuildConfig {
language: "python".to_string(),
..Default::default()
};
let result = build_project_call_graph_v2(dir.path(), config);
assert!(result.is_ok(), "Single file project should succeed");
let ir = result.unwrap();
assert_eq!(ir.file_count(), 1, "Should have 1 file");
assert!(
ir.function_count() >= 3,
"Should have at least 3 functions (foo, bar, baz)"
);
}
#[test]
fn test_build_with_imports() {
let dir = create_python_project();
let config = BuildConfig {
language: "python".to_string(),
..Default::default()
};
let result = build_project_call_graph_v2(dir.path(), config);
assert!(result.is_ok(), "Project with imports should succeed");
let ir = result.unwrap();
assert_eq!(
ir.file_count(),
2,
"Should have 2 files (main.py, helper.py)"
);
}
#[test]
fn test_build_cross_file_calls() {
let dir = create_python_project();
let config = BuildConfig {
language: "python".to_string(),
..Default::default()
};
let ir = build_project_call_graph_v2(dir.path(), config).unwrap();
let main_file = ir.get_file("main.py");
assert!(main_file.is_some(), "main.py should be in IR");
let main_ir = main_file.unwrap();
let calls: Vec<_> = main_ir
.calls
.values()
.flatten()
.filter(|c| c.target == "process")
.collect();
assert!(!calls.is_empty(), "Should have call to 'process'");
}
#[test]
fn test_build_method_resolution() {
let dir = create_method_call_project();
let config = BuildConfig {
language: "python".to_string(),
use_type_resolution: true,
..Default::default()
};
let ir = build_project_call_graph_v2(dir.path(), config).unwrap();
let service_file = ir.get_file("service.py");
assert!(service_file.is_some(), "service.py should be in IR");
let service_ir = service_file.unwrap();
let method_calls: Vec<_> = service_ir
.calls
.values()
.flatten()
.filter(|c| c.call_type == CallType::Method)
.collect();
assert!(
method_calls.len() >= 2,
"Should have at least 2 method calls"
);
}
}
mod parallel_processing {
use super::*;
#[test]
fn test_parallel_processing() {
let dir = create_large_project(100);
let config = BuildConfig {
language: "python".to_string(),
parallelism: 4, ..Default::default()
};
let ir1 = build_project_call_graph_v2(dir.path(), config.clone()).unwrap();
let ir2 = build_project_call_graph_v2(dir.path(), config).unwrap();
assert_eq!(
ir1.file_count(),
ir2.file_count(),
"File counts should match"
);
assert_eq!(
ir1.function_count(),
ir2.function_count(),
"Function counts should match"
);
let edges1: HashSet<String> = ir1
.files
.values()
.flat_map(|f| f.calls.values().flatten())
.map(|c| format!("{}:{}", c.caller, c.target))
.collect();
let edges2: HashSet<String> = ir2
.files
.values()
.flat_map(|f| f.calls.values().flatten())
.map(|c| format!("{}:{}", c.caller, c.target))
.collect();
assert_eq!(edges1, edges2, "Edge sets should be identical across runs");
}
#[test]
fn test_parallelism_auto_detect() {
let dir = create_intra_file_project();
let config = BuildConfig {
language: "python".to_string(),
parallelism: 0, ..Default::default()
};
let result = build_project_call_graph_v2(dir.path(), config);
assert!(result.is_ok(), "Auto parallelism should work");
}
}
mod memory_management {
use super::*;
#[test]
fn test_memory_bounded() {
let dir = create_large_project(500);
let config = BuildConfig {
language: "python".to_string(),
..Default::default()
};
let result = build_project_call_graph_v2(dir.path(), config);
assert!(result.is_ok(), "Large project should complete without OOM");
let ir = result.unwrap();
assert_eq!(ir.file_count(), 500, "Should process all 500 files");
}
#[test]
fn test_string_interning_dedup() {
let dir = create_large_project(100);
let config = BuildConfig {
language: "python".to_string(),
..Default::default()
};
let ir = build_project_call_graph_v2(dir.path(), config).unwrap();
assert_eq!(ir.file_count(), 100);
}
}
mod edge_cases {
use super::*;
#[test]
fn test_non_utf8_fallback() {
let dir = create_non_utf8_project();
let config = BuildConfig {
language: "python".to_string(),
..Default::default()
};
let result = build_project_call_graph_v2(dir.path(), config);
assert!(result.is_ok(), "Non-UTF8 file should not cause failure");
}
#[test]
#[cfg(unix)]
fn test_symlink_cycle_handling() {
let dir = create_symlink_project();
let config = BuildConfig {
language: "python".to_string(),
..Default::default()
};
let result = build_project_call_graph_v2(dir.path(), config);
assert!(
result.is_ok(),
"Symlink cycles should not cause infinite loop"
);
}
#[test]
fn test_root_not_found() {
let config = BuildConfig {
language: "python".to_string(),
..Default::default()
};
let result = build_project_call_graph_v2(Path::new("/nonexistent/path"), config);
assert!(result.is_err(), "Nonexistent root should fail");
assert!(matches!(result.unwrap_err(), BuildError::RootNotFound(_)));
}
#[test]
fn test_unsupported_language() {
let dir = TempDir::new().unwrap();
let config = BuildConfig {
language: "brainfuck".to_string(), ..Default::default()
};
let result = build_project_call_graph_v2(dir.path(), config);
assert!(result.is_err(), "Unsupported language should fail");
assert!(matches!(
result.unwrap_err(),
BuildError::UnsupportedLanguage(_)
));
}
}
mod build_config {
use super::*;
#[test]
fn test_build_config_defaults() {
let config = BuildConfig::default();
assert!(config.language.is_empty() || config.language == "python");
assert!(!config.use_workspace_config);
assert!(config.workspace_roots.is_empty());
assert!(!config.use_type_resolution);
assert!(config.respect_ignore);
assert_eq!(config.parallelism, 0); assert!(!config.verbose);
}
#[test]
fn test_workspace_config_filtering() {
let dir = TempDir::new().unwrap();
let pkg1 = dir.path().join("pkg1");
let pkg2 = dir.path().join("pkg2");
fs::create_dir(&pkg1).unwrap();
fs::create_dir(&pkg2).unwrap();
fs::write(pkg1.join("module.py"), "def foo(): pass").unwrap();
fs::write(pkg2.join("module.py"), "def bar(): pass").unwrap();
let config_all = BuildConfig {
language: "python".to_string(),
use_workspace_config: false,
..Default::default()
};
let ir_all = build_project_call_graph_v2(dir.path(), config_all).unwrap();
assert_eq!(
ir_all.file_count(),
2,
"Should find both packages without filtering"
);
let config_filtered = BuildConfig {
language: "python".to_string(),
use_workspace_config: true,
workspace_roots: vec![PathBuf::from("pkg1")],
..Default::default()
};
let ir_filtered = build_project_call_graph_v2(dir.path(), config_filtered).unwrap();
assert_eq!(ir_filtered.file_count(), 1, "Should only scan pkg1");
assert!(ir_filtered.get_file("pkg1/module.py").is_some());
assert!(ir_filtered.get_file("pkg2/module.py").is_none());
}
#[test]
fn test_respect_ignore_patterns() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("included.py"), "def foo(): pass").unwrap();
fs::write(dir.path().join("excluded.py"), "def bar(): pass").unwrap();
fs::write(dir.path().join(".tldrignore"), "excluded.py").unwrap();
let config = BuildConfig {
language: "python".to_string(),
respect_ignore: true,
..Default::default()
};
let ir = build_project_call_graph_v2(dir.path(), config).unwrap();
assert!(
ir.get_file("included.py").is_some(),
"included.py should be present"
);
assert!(
ir.get_file("excluded.py").is_none(),
"excluded.py should be ignored"
);
}
}