use super::*; use crate::walk::{walk_indexable_files, WalkEvent}; use std::collections::HashSet; use std::sync::atomic::AtomicBool; use std::sync::Arc; fn tmp_tree(tag: &str) -> PathBuf { crate::testutil::scratch_dir_canonical(tag) } fn touch(p: &Path) { std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(p, b"x").unwrap(); } fn empty_db(dir: &Path) -> PathBuf { let db = dir.join("index.sqlite"); crate::db::open_or_recreate(db.to_str().unwrap(), "trigram").unwrap(); db } /// Every file the walker actually emits under `config`'s single root. fn walked(config: &Config, db: &Path) -> HashSet { let root = config.paths.indexing_paths[0].clone(); walk_indexable_files( &[root], config.indexing.follow_symlinks, config.indexing.include_hidden, IgnoreSet::compile(&config.indexing.ignore_patterns).unwrap(), db.to_str().unwrap(), config.clone(), Arc::new(Registry::default_set()), Arc::new(AtomicBool::new(false)), 2, ) .filter_map(|e| match e { WalkEvent::File(f) => Some(PathBuf::from(f.path)), WalkEvent::Stale(_) => None, }) .collect() } /// One `files` row per path, which is all the scan reads. fn seed(conn: &mut Connection, paths: &[PathBuf]) { let tx = conn.transaction().unwrap(); for path in paths { let path = path.to_string_lossy(); let (parent, name) = path.rsplit_once('/').unwrap(); repo::insert_file( &tx, &repo::NewFile { name, path: &path, parent, size: 1, mtime: 1, mime: Some("text/plain"), ftype: crate::mime::FileType::TEXT, hash: None, needs_content: false, }, ) .unwrap() .expect("unique path"); } tx.commit().unwrap(); } /// Every file that physically exists under `root`, walker or no walker. fn on_disk(root: &Path) -> Vec { walkdir::WalkDir::new(root) .into_iter() .filter_map(Result::ok) .filter(|e| e.file_type().is_file()) .map(|e| e.into_path()) .collect() } /// `Scope` must reach the same verdict the walker does for every file on /// disk: stricter, and every prune deletes rows the next run puts straight /// back; laxer, and the rows the user excluded survive. #[test] fn scope_agrees_with_the_walker() { let root = tmp_tree("agree"); touch(&root.join("keep.txt")); touch(&root.join("sub/keep2.txt")); touch(&root.join("sub/skip.tmp")); touch(&root.join("sub/node_modules/dep/index.js")); touch(&root.join(".hidden/inside.txt")); touch(&root.join(".dotfile")); touch(&root.join("build/out/artifact.o")); touch(&root.join("build/keep3.txt")); touch(&root.join("nested/build/also.o")); let mut config = Config::default(); config.paths.indexing_paths = vec![root.to_string_lossy().into_owned()]; config.indexing.ignore_patterns = vec![ "*.tmp".into(), "node_modules".into(), // A full-path pattern: prunes this one directory, not every // directory called `out`. root.join("build/out").to_string_lossy().into_owned(), ]; for include_hidden in [false, true] { config.indexing.include_hidden = include_hidden; let db = empty_db(&tmp_tree("agree-db")); let emitted = walked(&config, &db); let scope = Scope::from_config(&config).unwrap(); for path in on_disk(&root) { assert_eq!( scope.covers(&root, &path), emitted.contains(&path), "disagreement on {} (include_hidden = {})", path.display(), include_hidden ); } } std::fs::remove_dir_all(&root).ok(); } /// A root is never filtered — the user chose it. A component pattern naming /// the root must not empty it out, but a full-path pattern matching the /// root still prunes everything below it. #[test] fn a_root_is_never_filtered_but_its_children_still_are() { let base = tmp_tree("root-name"); let root = base.join("node_modules"); touch(&root.join("keep.txt")); touch(&root.join("node_modules/nested.txt")); let mut config = Config::default(); config.paths.indexing_paths = vec![root.to_string_lossy().into_owned()]; config.indexing.ignore_patterns = vec!["node_modules".into()]; let scope = Scope::from_config(&config).unwrap(); assert!(scope.covers(&root, &root.join("keep.txt"))); assert!(!scope.covers(&root, &root.join("node_modules/nested.txt"))); let db = empty_db(&tmp_tree("root-name-db")); let emitted = walked(&config, &db); for path in on_disk(&root) { assert_eq!(scope.covers(&root, &path), emitted.contains(&path)); } // A full-path pattern reaching the root itself takes the whole tree. config.indexing.ignore_patterns = vec![root.to_string_lossy().into_owned()]; let scope = Scope::from_config(&config).unwrap(); assert!(!scope.covers(&root, &root.join("keep.txt"))); std::fs::remove_dir_all(&base).ok(); } /// The counters the status display reads: a scan that reports nothing is /// indistinguishable from a hang. #[test] fn the_scan_reports_its_way_through_every_row() { let root = tmp_tree("progress"); for i in 0..7 { touch(&root.join(format!("f{}.log", i))); } touch(&root.join("keep.txt")); let db_dir = tmp_tree("progress-db"); let db = empty_db(&db_dir); let mut conn = crate::db::open_existing(db.to_str().unwrap(), true).unwrap(); let mut config = Config::default(); config.paths.indexing_paths = vec![root.to_string_lossy().into_owned()]; // One row per page, so a counter that only moved at the end is visible. config.processing.batch_size = 1; seed(&mut conn, &on_disk(&root)); let mut narrowed = config.clone(); narrowed.indexing.ignore_patterns = vec!["*.log".into()]; let work = crate::config::diff_actions(&config, &narrowed).work; let mut cursor = WorkCursor::new(work, &narrowed).unwrap(); assert_eq!( cursor.progress(), ReconcileProgress::default(), "nothing counted before the first slice" ); let registry = Registry::default_set(); let run = AtomicBool::new(false); let mut seen: Vec = Vec::new(); while !cursor.done() { // A deadline already past, so each call does the least it can and // the counters are sampled at their finest granularity. advance( &mut conn, &narrowed, ®istry, &mut cursor, Instant::now(), &run, ) .unwrap(); seen.push(cursor.progress().examined); } let end = cursor.progress(); assert_eq!(end.total, Some(8), "counted once, before the first page"); assert_eq!(end.examined, 8, "every row was re-tested"); assert_eq!(end.deleted, 7, "the logs, and only the logs"); assert!( seen.windows(2).all(|w| w[0] <= w[1]), "the count never goes backwards: {:?}", seen ); assert!( seen.len() > 2 && seen[0] < end.examined, "progress was reported during the scan, not only at its end: {:?}", seen ); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); } /// Cancelling stops the pass at the next statement boundary and leaves the /// cursor un-finished, so nothing downstream can record the configuration /// as reconciled. Rows already reached stay gone — the pass is idempotent /// and the next run finishes it. #[test] fn cancelling_stops_the_scan_without_finishing_it() { let root = tmp_tree("cancel"); for i in 0..6 { touch(&root.join(format!("f{}.log", i))); } touch(&root.join("keep.txt")); let db_dir = tmp_tree("cancel-db"); let db = empty_db(&db_dir); let mut conn = crate::db::open_existing(db.to_str().unwrap(), true).unwrap(); let mut config = Config::default(); config.paths.indexing_paths = vec![root.to_string_lossy().into_owned()]; config.processing.batch_size = 1; seed(&mut conn, &on_disk(&root)); let mut narrowed = config.clone(); narrowed.indexing.ignore_patterns = vec!["*.log".into()]; let work = crate::config::diff_actions(&config, &narrowed).work; let registry = Registry::default_set(); // Cancelled from the outset: not one statement runs. let stop = AtomicBool::new(true); let mut cursor = WorkCursor::new(work.clone(), &narrowed).unwrap(); advance( &mut conn, &narrowed, ®istry, &mut cursor, Instant::now() + SLICE, &stop, ) .unwrap(); assert!(!cursor.done(), "a cancelled pass is never finished"); assert_eq!( cursor.progress(), ReconcileProgress::default(), "a cancelled pass touched the index" ); // And part-way through: one slice with the flag clear, the rest with // it set. The counters keep what the first slice earned. let stop = AtomicBool::new(false); let mut cursor = WorkCursor::new(work, &narrowed).unwrap(); advance( &mut conn, &narrowed, ®istry, &mut cursor, Instant::now(), &stop, ) .unwrap(); let part_way = cursor.progress(); assert!(part_way.examined > 0 && !cursor.done(), "nothing to cancel"); stop.store(true, Ordering::Relaxed); advance( &mut conn, &narrowed, ®istry, &mut cursor, Instant::now() + SLICE, &stop, ) .unwrap(); assert!(!cursor.done(), "the pass finished despite the cancellation"); assert_eq!( cursor.progress(), part_way, "the cancelled slice did more work" ); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); }