diff --git a/src/ast.rs b/src/ast.rs index 3502c62a..6b3ac445 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -73,3 +73,30 @@ pub(crate) fn run_rules_on_file(path: &Path, cfg: &Config) -> NyxResult NyxResult> { let mut diags = acc.into_inner()?; if let Some(max) = cfg.output.max_results { - diags.truncate(max as usize); + diags.truncate(max as usize); } - + Ok(diags) } @@ -120,7 +120,7 @@ pub fn scan_with_index_parallel( let idx = Indexer::from_pool(project, &pool)?; idx.get_files(project)? }; - + let diag_map: DashMap> = DashMap::new(); files.into_par_iter().for_each_init( @@ -156,12 +156,12 @@ pub fn scan_with_index_parallel( // Optional, heavy: only vacuum on --rebuild-index // if rebuild { idx.vacuum()?; } - + let mut diags: Vec = diag_map.into_iter().flat_map(|(_, v)| v).collect(); if let Some(max) = cfg.output.max_results { diags.truncate(max as usize); } - + Ok(diags) } diff --git a/src/database.rs b/src/database.rs index 37a13226..f4d5ab0e 100644 --- a/src/database.rs +++ b/src/database.rs @@ -240,3 +240,84 @@ pub mod index { } } } + +#[test] +fn indexer_should_scan_and_upsert_logic() { + let td = tempfile::tempdir().unwrap(); + let db = td.path().join("nyx.sqlite"); + let file = td.path().join("sample.rs"); + std::fs::write(&file, "fn main() {}").unwrap(); + + let pool = index::Indexer::init(&db).unwrap(); + let idx = index::Indexer::from_pool("proj", &pool).unwrap(); + + // first time: nothing in DB → must scan + assert!(idx.should_scan(&file).unwrap()); + + // after upsert: no changes → should *not* scan + idx.upsert_file(&file).unwrap(); + assert!(!idx.should_scan(&file).unwrap()); + + // modify contents + std::thread::sleep(std::time::Duration::from_millis(25)); // ensure mtime tick + std::fs::write(&file, "fn main() { /* changed */ }").unwrap(); + assert!(idx.should_scan(&file).unwrap()); +} + +#[test] +fn replace_issues_and_query_back() { + let td = tempfile::tempdir().unwrap(); + let db = td.path().join("nyx.sqlite"); + let file = td.path().join("code.go"); + std::fs::write(&file, "package main").unwrap(); + + let pool = index::Indexer::init(&db).unwrap(); + let mut idx = index::Indexer::from_pool("proj", &pool).unwrap(); + let fid = idx.upsert_file(&file).unwrap(); + + let issues = [ + index::IssueRow { + rule_id: "X1", + severity: "High", + line: 3, + col: 7, + }, + index::IssueRow { + rule_id: "X2", + severity: "Low", + line: 4, + col: 1, + }, + ]; + idx.replace_issues(fid, issues.clone()).unwrap(); + + let stored = idx.get_issues_from_file(&file).unwrap(); + assert_eq!(stored.len(), 2); + assert!( + stored + .iter() + .any(|d| d.id == "X1" && d.severity == crate::patterns::Severity::High) + ); + assert!( + stored + .iter() + .any(|d| d.id == "X2" && d.severity == crate::patterns::Severity::Low) + ); +} + +#[test] +fn clear_and_vacuum_reset_tables() { + let td = tempfile::tempdir().unwrap(); + let db = td.path().join("nyx.sqlite"); + let f = td.path().join("f.rs"); + std::fs::write(&f, "//").unwrap(); + + let pool = index::Indexer::init(&db).unwrap(); + let idx = index::Indexer::from_pool("proj", &pool).unwrap(); + idx.upsert_file(&f).unwrap(); + + assert!(!idx.get_files("proj").unwrap().is_empty()); + idx.clear().unwrap(); + idx.vacuum().unwrap(); + assert!(idx.get_files("proj").unwrap().is_empty()); +} diff --git a/src/errors.rs b/src/errors.rs index a9ce34ae..d19ca354 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -61,3 +61,37 @@ impl From> for NyxError { NyxError::Msg(err.to_string()) } } + +#[test] +fn io_conversion_retains_message() { + let e = std::io::Error::other("boom!"); + let n: NyxError = e.into(); + assert!(matches!(n, NyxError::Io(_))); + assert!(n.to_string().contains("boom")); +} + +#[test] +fn poison_conversion_maps_correct_variant() { + let lock = std::sync::Arc::new(std::sync::Mutex::new(())); + + { + let lock2 = std::sync::Arc::clone(&lock); + std::thread::spawn(move || { + let _guard = lock2.lock().unwrap(); + panic!("intentional – poison the mutex"); + }) + .join() + .ok(); + } + + let poison = lock.lock().unwrap_err(); + let nyx: NyxError = poison.into(); + + assert!(matches!(nyx, NyxError::Poison(_))); +} + +#[test] +fn simple_string_into_msg() { + let nyx: NyxError = "plain msg".into(); + assert!(matches!(nyx, NyxError::Msg(s) if s == "plain msg")); +} diff --git a/src/utils/config.rs b/src/utils/config.rs index a72f4548..5d5339d1 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -285,23 +285,26 @@ fn merge_configs(mut default: Config, user: Config) -> Config { #[test] fn merge_configs_dedupes_and_keeps_order() { - let mut default_cfg = Config::default(); - default_cfg.scanner.excluded_extensions = vec!["rs".into(), "toml".into()]; + let mut default_cfg = Config::default(); + default_cfg.scanner.excluded_extensions = vec!["rs".into(), "toml".into()]; - let mut user_cfg = Config::default(); - user_cfg.scanner.excluded_extensions = vec!["jpg".into(), "rs".into()]; + let mut user_cfg = Config::default(); + user_cfg.scanner.excluded_extensions = vec!["jpg".into(), "rs".into()]; - let merged = merge_configs(default_cfg, user_cfg); - - assert_eq!(merged.scanner.excluded_extensions, vec!["jpg", "rs", "toml"]); + let merged = merge_configs(default_cfg, user_cfg); + + assert_eq!( + merged.scanner.excluded_extensions, + vec!["jpg", "rs", "toml"] + ); } #[test] fn load_creates_example_and_reads_user_overrides() { - let cfg_dir = tempfile::tempdir().unwrap(); - let cfg_path = cfg_dir.path(); - - let user_toml = r#" + let cfg_dir = tempfile::tempdir().unwrap(); + let cfg_path = cfg_dir.path(); + + let user_toml = r#" [scanner] one_file_system = true excluded_extensions = ["foo"] @@ -309,15 +312,15 @@ fn load_creates_example_and_reads_user_overrides() { [output] quiet = true "#; - fs::write(cfg_path.join("nyx.local"), user_toml).unwrap(); + fs::write(cfg_path.join("nyx.local"), user_toml).unwrap(); - let cfg = Config::load(cfg_path).expect("Config::load should succeed"); + let cfg = Config::load(cfg_path).expect("Config::load should succeed"); - assert!(cfg_path.join("nyx.conf").is_file()); - - assert!(cfg.scanner.one_file_system); - assert!(cfg.output.quiet); - assert!(cfg.scanner.excluded_extensions.contains(&"foo".to_string())); + assert!(cfg_path.join("nyx.conf").is_file()); - assert!(!cfg.scanner.follow_symlinks); + assert!(cfg.scanner.one_file_system); + assert!(cfg.output.quiet); + assert!(cfg.scanner.excluded_extensions.contains(&"foo".to_string())); + + assert!(!cfg.scanner.follow_symlinks); } diff --git a/src/utils/ext.rs b/src/utils/ext.rs index 11fe5e0d..302350ac 100644 --- a/src/utils/ext.rs +++ b/src/utils/ext.rs @@ -15,16 +15,20 @@ pub fn lowercase_ext(path: &std::path::Path) -> Option<&'static str> { #[test] fn lowercase_ext_recognises_known_extensions() { - let cases = [ - ("file.rs", Some("rs")), - ("FILE.RS", Some("rs")), - ("main.cpp", Some("cpp")), - ("script.PY",Some("py")), - ("index.tsx",Some("ts")), - ("style.css",None), // unsupported - ]; + let cases = [ + ("file.rs", Some("rs")), + ("FILE.RS", Some("rs")), + ("main.cpp", Some("cpp")), + ("script.PY", Some("py")), + ("index.tsx", Some("ts")), + ("style.css", None), // unsupported + ]; - for (file, expected) in cases { - assert_eq!(lowercase_ext(std::path::Path::new(file)), expected, "case: {file}"); - } + for (file, expected) in cases { + assert_eq!( + lowercase_ext(std::path::Path::new(file)), + expected, + "case: {file}" + ); + } } diff --git a/src/utils/project.rs b/src/utils/project.rs index 5bc10074..269ee0e8 100644 --- a/src/utils/project.rs +++ b/src/utils/project.rs @@ -31,33 +31,32 @@ pub fn sanitize_project_name(name: &str) -> String { #[test] fn sanitize_project_name_is_idempotent_and_lossless_enough() { - let samples = [ - ("My Project", "my_project"), - ("Hello-World", "hello-world"), - ("mixed_case", "mixed_case"), - ("tabs\tspaces\n", "tabs_spaces"), - (" multiple ", "multiple"), - ("weird@$*chars", "weird_chars"), - ]; + let samples = [ + ("My Project", "my_project"), + ("Hello-World", "hello-world"), + ("mixed_case", "mixed_case"), + ("tabs\tspaces\n", "tabs_spaces"), + (" multiple ", "multiple"), + ("weird@$*chars", "weird_chars"), + ]; - for (input, expected) in samples { - assert_eq!(sanitize_project_name(input), expected, "input: {}", input); - assert_eq!(sanitize_project_name(expected), expected); - } + for (input, expected) in samples { + assert_eq!(sanitize_project_name(input), expected, "input: {}", input); + assert_eq!(sanitize_project_name(expected), expected); + } } #[test] fn get_project_info_uses_sanitized_name_in_sqlite_path() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - - let project_dir = root.join("Example Project"); - std::fs::create_dir(&project_dir).unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); - let (project_name, db_path) = - get_project_info(&project_dir, root).expect("should detect project"); + let project_dir = root.join("Example Project"); + std::fs::create_dir(&project_dir).unwrap(); - assert_eq!(project_name, "Example Project"); - assert_eq!(db_path, root.join("example_project.sqlite")); + let (project_name, db_path) = + get_project_info(&project_dir, root).expect("should detect project"); + + assert_eq!(project_name, "Example Project"); + assert_eq!(db_path, root.join("example_project.sqlite")); } - diff --git a/src/walk.rs b/src/walk.rs index ae1165a7..d3242c21 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -105,3 +105,23 @@ pub fn spawn_senders(root: &Path, cfg: &Config) -> Receiver { rx } + +#[test] +fn walker_respects_excluded_extensions() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("keep.rs"), "fn main(){}").unwrap(); + std::fs::write(tmp.path().join("skip.txt"), "ignored").unwrap(); + + let mut cfg = Config::default(); + cfg.scanner.excluded_extensions = vec!["txt".into()]; + cfg.performance.worker_threads = Some(1); + cfg.performance.channel_multiplier = 1; + cfg.performance.batch_size = 2; + + let rx = spawn_senders(tmp.path(), &cfg); + + let all: Vec<_> = rx.into_iter().flatten().collect(); + + assert!(all.iter().any(|p| p.ends_with("keep.rs"))); + assert!(all.iter().all(|p| !p.ends_with("skip.txt"))); +}