use nono::undo::ExclusionFilter;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use walkdir::WalkDir;
const KNOWN_HEAVY_DIRS: &[(&str, &str)] = &[
(".git", "VCS internals"),
("target", "build artifacts"),
("node_modules", "dependencies"),
("__pycache__", "Python bytecode cache"),
(".venv", "Python virtual environment"),
(".tox", "tox environments"),
("dist", "distribution artifacts"),
("build", "build output"),
(".next", "Next.js build"),
(".nuxt", "Nuxt.js build"),
(".gradle", "Gradle cache"),
(".cache", "cache directory"),
];
const SIZE_THRESHOLD: usize = 10_000;
const SIZE_CHECK_TIME_CAP: Duration = Duration::from_secs(1);
const SIZE_CHECK_TOTAL_CAP: Duration = Duration::from_secs(5);
const PROBE_ENTRY_CAP: usize = 5_000;
const PROBE_TIME_CAP: Duration = Duration::from_secs(2);
pub(crate) struct PreflightResult {
pub heavy_dirs: Vec<HeavyDir>,
pub probe_file_count: usize,
pub probe_capped: bool,
pub probe_duration: Duration,
}
pub(crate) struct HeavyDir {
pub path: PathBuf,
pub name: String,
pub description: String,
}
impl PreflightResult {
pub fn needs_warning(&self) -> bool {
!self.heavy_dirs.is_empty()
}
}
pub(crate) fn run_preflight(
tracked_paths: &[PathBuf],
exclusion: &ExclusionFilter,
extra_skip_dirs: &[String],
) -> PreflightResult {
let heavy_dirs = detect_heavy_dirs(tracked_paths, exclusion, extra_skip_dirs);
if heavy_dirs.is_empty() {
return PreflightResult {
heavy_dirs,
probe_file_count: 0,
probe_capped: false,
probe_duration: Duration::ZERO,
};
}
let start = Instant::now();
let mut file_count: usize = 0;
let mut capped = false;
'outer: for tracked in tracked_paths {
if !tracked.exists() || tracked.is_file() {
continue;
}
for entry in WalkDir::new(tracked)
.follow_links(false)
.into_iter()
.filter_entry(|e| !exclusion.is_excluded(e.path()))
.filter_map(|e| e.ok())
{
if entry.path().is_file() {
file_count = file_count.saturating_add(1);
}
if file_count >= PROBE_ENTRY_CAP || start.elapsed() >= PROBE_TIME_CAP {
capped = true;
break 'outer;
}
}
}
PreflightResult {
heavy_dirs,
probe_file_count: file_count,
probe_capped: capped,
probe_duration: start.elapsed(),
}
}
fn detect_heavy_dirs(
tracked_paths: &[PathBuf],
exclusion: &ExclusionFilter,
extra_skip_dirs: &[String],
) -> Vec<HeavyDir> {
let mut found = Vec::new();
let mut size_check_candidates = Vec::new();
for tracked in tracked_paths {
if !tracked.exists() || tracked.is_file() {
continue;
}
let entries = match std::fs::read_dir(tracked) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name_str) = path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
else {
continue;
};
if exclusion.is_excluded(&path) {
continue;
}
if let Some((_, description)) = KNOWN_HEAVY_DIRS.iter().find(|(n, _)| *n == name_str) {
found.push(HeavyDir {
path,
name: name_str,
description: (*description).to_string(),
});
} else if extra_skip_dirs.iter().any(|dir| dir == &name_str) {
found.push(HeavyDir {
path,
name: name_str,
description: "user-defined skip directory".to_string(),
});
} else {
size_check_candidates.push((path, name_str));
}
}
}
let size_check_start = Instant::now();
for (path, name_str) in size_check_candidates {
if size_check_start.elapsed() >= SIZE_CHECK_TOTAL_CAP {
break;
}
if exceeds_file_threshold(&path, exclusion) {
found.push(HeavyDir {
path,
name: name_str,
description: format!("large directory (>{SIZE_THRESHOLD} files)"),
});
}
}
found.sort_by(|a, b| a.name.cmp(&b.name));
found.dedup_by(|a, b| a.name == b.name);
found
}
fn exceeds_file_threshold(path: &std::path::Path, exclusion: &ExclusionFilter) -> bool {
let start = Instant::now();
let mut count: usize = 0;
for entry in WalkDir::new(path)
.follow_links(false)
.into_iter()
.filter_entry(|e| !exclusion.is_excluded(e.path()))
.filter_map(|e| e.ok())
{
if entry.path().is_file() {
count = count.saturating_add(1);
}
if count > SIZE_THRESHOLD {
return true;
}
if start.elapsed() >= SIZE_CHECK_TIME_CAP {
return count > SIZE_THRESHOLD / 2;
}
}
false
}
pub(crate) fn print_auto_exclude_notice(excluded: &[&HeavyDir], result: &PreflightResult) {
let names: Vec<String> = excluded
.iter()
.map(|d| format!("{} ({})", d.path.display(), d.description))
.collect();
let file_info = if result.probe_capped {
format!(">{} files", result.probe_file_count)
} else {
format!("~{} files", result.probe_file_count)
};
eprintln!(
" [nono] Rollback: auto-excluded {} [{}] in {:.1}s. \
Use --rollback-include <name> or --rollback-all to include.",
names.join(", "),
file_info,
result.probe_duration.as_secs_f64(),
);
}
#[cfg(test)]
mod tests {
use super::*;
use nono::undo::ExclusionConfig;
fn make_filter(patterns: Vec<&str>) -> ExclusionFilter {
let dir = tempfile::TempDir::new().expect("tempdir");
let config = ExclusionConfig {
use_gitignore: false,
exclude_patterns: patterns.into_iter().map(String::from).collect(),
exclude_globs: Vec::new(),
force_include: Vec::new(),
};
ExclusionFilter::new(config, dir.path()).expect("filter")
}
#[test]
fn preflight_result_fields() {
let result = PreflightResult {
heavy_dirs: vec![
HeavyDir {
path: PathBuf::from("/a/target"),
name: "target".to_string(),
description: "build artifacts".to_string(),
},
HeavyDir {
path: PathBuf::from("/a/node_modules"),
name: "node_modules".to_string(),
description: "dependencies".to_string(),
},
],
probe_file_count: 5000,
probe_capped: true,
probe_duration: Duration::from_millis(800),
};
let names: Vec<&str> = result.heavy_dirs.iter().map(|d| d.name.as_str()).collect();
assert_eq!(names, vec!["target", "node_modules"]);
assert!(result.needs_warning());
assert_eq!(result.probe_file_count, 5000);
assert!(result.probe_capped);
assert_eq!(result.probe_duration, Duration::from_millis(800));
}
#[test]
fn empty_result_does_not_need_warning() {
let result = PreflightResult {
heavy_dirs: vec![],
probe_file_count: 0,
probe_capped: false,
probe_duration: Duration::ZERO,
};
assert!(!result.needs_warning());
assert_eq!(result.probe_file_count, 0);
assert!(!result.probe_capped);
}
#[test]
fn heavy_dir_fields_accessible() {
let hd = HeavyDir {
path: PathBuf::from("/project/target"),
name: "target".to_string(),
description: "build artifacts".to_string(),
};
assert_eq!(hd.path, PathBuf::from("/project/target"));
assert_eq!(hd.description, "build artifacts");
}
#[test]
fn detect_heavy_dirs_finds_known_names() {
let dir = tempfile::TempDir::new().expect("tempdir");
let tracked = dir.path().join("project");
std::fs::create_dir_all(tracked.join("target")).expect("create target");
std::fs::create_dir_all(tracked.join("node_modules")).expect("create node_modules");
std::fs::create_dir_all(tracked.join("src")).expect("create src");
let filter = make_filter(vec![]);
let heavy = detect_heavy_dirs(&[tracked], &filter, &[]);
let names: Vec<&str> = heavy.iter().map(|d| d.name.as_str()).collect();
assert!(names.contains(&"target"), "Should detect target/");
assert!(
names.contains(&"node_modules"),
"Should detect node_modules/"
);
assert!(!names.contains(&"src"), "src/ is not a known heavy dir");
}
#[test]
fn detect_heavy_dirs_skips_already_excluded() {
let dir = tempfile::TempDir::new().expect("tempdir");
let tracked = dir.path().join("project");
std::fs::create_dir_all(tracked.join("target")).expect("create target");
std::fs::create_dir_all(tracked.join("node_modules")).expect("create node_modules");
let filter = make_filter(vec!["target"]);
let heavy = detect_heavy_dirs(&[tracked], &filter, &[]);
let names: Vec<&str> = heavy.iter().map(|d| d.name.as_str()).collect();
assert!(
!names.contains(&"target"),
"Already-excluded target should not appear"
);
assert!(
names.contains(&"node_modules"),
"node_modules should appear"
);
}
#[test]
fn preflight_empty_tracked_dir_no_warning() {
let dir = tempfile::TempDir::new().expect("tempdir");
let tracked = dir.path().join("empty_project");
std::fs::create_dir_all(&tracked).expect("create empty dir");
let filter = make_filter(vec![]);
let result = run_preflight(&[tracked], &filter, &[]);
assert!(!result.needs_warning());
}
#[test]
fn preflight_nonexistent_path_no_warning() {
let filter = make_filter(vec![]);
let result = run_preflight(&[PathBuf::from("/nonexistent/path/xyz")], &filter, &[]);
assert!(!result.needs_warning());
assert_eq!(result.probe_file_count, 0);
}
}