use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, OnceLock, RwLock};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
const DEFAULT_EXCLUDE_DIRS: &[&str] = &["node_modules", "target", ".git", ".venv", "__pycache__"];
const DEFAULT_MAX_FILE_MB: u64 = 20;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IndexRules {
#[serde(default = "default_exclude_dirs")]
pub exclude_dirs: Vec<String>,
#[serde(default)]
pub extra_text_exts: Vec<String>,
#[serde(default = "default_max_file_mb")]
pub max_file_mb: u64,
}
fn default_exclude_dirs() -> Vec<String> {
DEFAULT_EXCLUDE_DIRS.iter().map(|s| s.to_string()).collect()
}
fn default_max_file_mb() -> u64 {
DEFAULT_MAX_FILE_MB
}
impl Default for IndexRules {
fn default() -> Self {
Self {
exclude_dirs: default_exclude_dirs(),
extra_text_exts: Vec::new(),
max_file_mb: DEFAULT_MAX_FILE_MB,
}
}
}
impl IndexRules {
pub fn normalize(&mut self) {
self.exclude_dirs = dedup_preserving_order(
self.exclude_dirs
.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
);
self.extra_text_exts = dedup_preserving_order(
self.extra_text_exts
.iter()
.map(|s| s.trim().trim_start_matches('.').to_ascii_lowercase())
.filter(|s| !s.is_empty()),
);
}
pub(crate) fn max_file_bytes(&self) -> u64 {
self.max_file_mb.saturating_mul(1024 * 1024)
}
pub(crate) fn is_dir_excluded(&self, name: &str) -> bool {
name.starts_with('.') || self.exclude_dirs.iter().any(|d| d == name)
}
pub(crate) fn is_extra_text_ext(&self, ext: &str) -> bool {
self.extra_text_exts.iter().any(|e| e == ext)
}
pub(crate) fn path_under_excluded_dir(&self, path: &Path, root: &Path) -> bool {
let Ok(rel) = path.strip_prefix(root) else {
return false;
};
let comps: Vec<Component> = rel.components().collect();
for comp in comps.iter().take(comps.len().saturating_sub(1)) {
if let Component::Normal(name) = comp
&& self.is_dir_excluded(&name.to_string_lossy())
{
return true;
}
}
false
}
}
fn dedup_preserving_order(items: impl Iterator<Item = String>) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for item in items {
if !out.contains(&item) {
out.push(item);
}
}
out
}
fn rules_path(index_dir: &Path) -> PathBuf {
let stem = index_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("dowse-index");
index_dir.with_file_name(format!("{stem}-rules.json"))
}
pub fn load_rules(index_dir: &Path) -> IndexRules {
let path = rules_path(index_dir);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(_) => return IndexRules::default(),
};
match serde_json::from_slice::<IndexRules>(&bytes) {
Ok(mut rules) => {
rules.normalize();
rules
}
Err(err) => {
eprintln!(
"规则文件 {} 解析失败,本次回落到默认规则: {err}",
path.display()
);
IndexRules::default()
}
}
}
pub fn save_rules(index_dir: &Path, rules: &IndexRules) -> Result<()> {
let mut rules = rules.clone();
rules.normalize();
let path = rules_path(index_dir);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let bytes = serde_json::to_vec_pretty(&rules)?;
std::fs::write(&path, bytes).context("写规则文件失败")?;
Ok(())
}
fn active_lock() -> &'static RwLock<Arc<IndexRules>> {
static ACTIVE: OnceLock<RwLock<Arc<IndexRules>>> = OnceLock::new();
ACTIVE.get_or_init(|| RwLock::new(Arc::new(IndexRules::default())))
}
pub(crate) fn active_rules() -> Arc<IndexRules> {
active_lock()
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
pub(crate) fn set_active_rules(rules: IndexRules) {
*active_lock().write().unwrap_or_else(|e| e.into_inner()) = Arc::new(rules);
}
pub(crate) fn load_active_rules(index_dir: &Path) -> IndexRules {
let rules = load_rules(index_dir);
set_active_rules(rules.clone());
rules
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_matches_pre_config_hardcoded_behavior() {
let rules = IndexRules::default();
assert_eq!(
rules.exclude_dirs,
vec!["node_modules", "target", ".git", ".venv", "__pycache__"]
);
assert!(rules.extra_text_exts.is_empty());
assert_eq!(rules.max_file_mb, 20);
assert_eq!(rules.max_file_bytes(), 20 * 1024 * 1024);
}
#[test]
fn missing_rules_file_falls_back_to_default() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(load_rules(dir.path()), IndexRules::default());
}
#[test]
fn save_then_load_round_trips() {
let dir = tempfile::tempdir().unwrap();
let index_dir = dir.path().join("index");
let rules = IndexRules {
exclude_dirs: vec!["node_modules".into(), "dist".into()],
extra_text_exts: vec!["rst".into(), "adoc".into()],
max_file_mb: 64,
};
save_rules(&index_dir, &rules).unwrap();
assert_eq!(load_rules(&index_dir), rules);
}
#[test]
fn missing_field_in_json_falls_back_per_field() {
let dir = tempfile::tempdir().unwrap();
let index_dir = dir.path().join("idx");
std::fs::create_dir_all(&index_dir).unwrap();
let path = rules_path(&index_dir);
std::fs::write(&path, r#"{"max_file_mb": 7}"#).unwrap();
let rules = load_rules(&index_dir);
assert_eq!(rules.max_file_mb, 7);
assert_eq!(rules.exclude_dirs, default_exclude_dirs());
assert!(rules.extra_text_exts.is_empty());
}
#[test]
fn corrupted_json_falls_back_to_default() {
let dir = tempfile::tempdir().unwrap();
let index_dir = dir.path().join("idx");
std::fs::create_dir_all(&index_dir).unwrap();
std::fs::write(rules_path(&index_dir), b"this is not json at all {[").unwrap();
assert_eq!(load_rules(&index_dir), IndexRules::default());
}
#[test]
fn normalize_lowercases_strips_dots_and_dedups() {
let mut rules = IndexRules {
exclude_dirs: vec![
" node_modules ".into(),
"dist".into(),
"dist".into(),
"".into(),
],
extra_text_exts: vec![".RST".into(), "adoc".into(), "ADOC".into(), " ".into()],
max_file_mb: 10,
};
rules.normalize();
assert_eq!(rules.exclude_dirs, vec!["node_modules", "dist"]);
assert_eq!(rules.extra_text_exts, vec!["rst", "adoc"]);
}
#[test]
fn is_dir_excluded_covers_list_and_dot_prefix() {
let rules = IndexRules {
exclude_dirs: vec!["build".into()],
extra_text_exts: vec![],
max_file_mb: 20,
};
assert!(rules.is_dir_excluded("build"));
assert!(rules.is_dir_excluded(".git"), "点开头的隐藏目录始终排除");
assert!(!rules.is_dir_excluded("src"));
assert!(!rules.is_dir_excluded("node_modules"));
}
#[test]
fn path_under_excluded_dir_checks_intermediate_dirs_only() {
let rules = IndexRules {
exclude_dirs: vec!["node_modules".into()],
extra_text_exts: vec![],
max_file_mb: 20,
};
let root = Path::new("/proj");
assert!(rules.path_under_excluded_dir(Path::new("/proj/node_modules/x/a.js"), root));
assert!(!rules.path_under_excluded_dir(Path::new("/proj/src/a.rs"), root));
assert!(!rules.path_under_excluded_dir(Path::new("/proj/src/node_modules"), root));
}
}