pub mod helpers;
pub mod settings;
mod watched_file;
use crate::helpers::expand_tilde;
pub use crate::settings::ConfigSettings;
use crate::watched_file::WatchedFile;
use log::debug;
use log::info;
use regex::Regex;
use std::env;
use std::fmt::Debug;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
static CACHE_DIR: &str = "codebook";
static GLOBAL_CONFIG_FILE: &str = "codebook.toml";
static USER_CONFIG_FILES: [&str; 2] = ["codebook.toml", ".codebook.toml"];
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to read config file {path}: {source}")]
Read { path: PathBuf, source: io::Error },
#[error("failed to parse config file {path}: {source}")]
Parse {
path: PathBuf,
source: toml::de::Error,
},
#[error("failed to write config file {path}: {source}")]
Write { path: PathBuf, source: io::Error },
#[error("failed to serialize config: {0}")]
Serialize(#[from] toml::ser::Error),
#[error(transparent)]
Io(#[from] io::Error),
}
pub trait CodebookConfig: Sync + Send + Debug {
fn add_word(&self, word: &str) -> bool;
fn add_word_global(&self, word: &str) -> bool;
fn add_ignore(&self, file: &str) -> bool;
fn add_include(&self, file: &str) -> bool;
fn get_dictionary_ids(&self) -> Vec<String>;
fn should_ignore_path(&self, path: &Path) -> bool;
fn should_include_path(&self, path: &Path) -> bool;
fn is_allowed_word(&self, word: &str) -> bool;
fn should_flag_word(&self, word: &str) -> bool;
fn get_ignore_patterns(&self) -> Vec<Regex>;
fn get_min_word_length(&self) -> usize;
fn should_check_tag(&self, tag: &str) -> bool;
fn cache_dir(&self) -> &Path;
fn resolve_for_file(&self, _relative_path: &Path) -> Option<Arc<ConfigSettings>> {
None
}
}
#[derive(Debug)]
struct ConfigInner {
project_config: WatchedFile<ConfigSettings>,
global_config: WatchedFile<ConfigSettings>,
snapshot: Arc<ConfigSettings>,
}
#[derive(Debug)]
pub struct CodebookConfigFile {
inner: RwLock<ConfigInner>,
pub cache_dir: PathBuf,
}
impl Default for CodebookConfigFile {
fn default() -> Self {
let inner = ConfigInner {
project_config: WatchedFile::new(None),
global_config: WatchedFile::new(None),
snapshot: Arc::new(ConfigSettings::default()),
};
Self {
inner: RwLock::new(inner),
cache_dir: helpers::default_cache_dir(),
}
}
}
impl CodebookConfigFile {
pub fn load(current_dir: Option<&Path>) -> Result<Self, ConfigError> {
Self::load_with_overrides(current_dir, None, None)
}
pub fn load_with_overrides(
current_dir: Option<&Path>,
global_config_path: Option<PathBuf>,
project_config_path: Option<PathBuf>,
) -> Result<Self, ConfigError> {
debug!("Initializing CodebookConfig");
let current_dir = match current_dir {
Some(p) => PathBuf::from(p),
None => env::current_dir()?,
};
Self::load_configs(¤t_dir, global_config_path, project_config_path)
}
fn load_configs(
start_dir: &Path,
global_config_override: Option<PathBuf>,
project_config_override: Option<PathBuf>,
) -> Result<Self, ConfigError> {
let start_dir = start_dir
.canonicalize()
.unwrap_or_else(|_| start_dir.to_path_buf());
let start_dir = start_dir.as_path();
let config = Self::default();
let mut inner = config.inner.write().unwrap();
let global_config_path = match global_config_override {
Some(path) => Some(expand_tilde(&path).unwrap_or(path)),
None => Self::find_global_config_path(),
};
if let Some(global_path) = global_config_path {
let global_config = WatchedFile::new(Some(global_path.clone()));
if global_path.exists() {
inner.global_config =
global_config.load(|path| Self::load_settings_from_file(path))?;
debug!("Loaded global config from {}", global_path.display());
} else {
info!("No global config found, using default");
inner.global_config = global_config;
}
}
let project_path = match project_config_override.map(|p| expand_tilde(&p).unwrap_or(p)) {
Some(override_path) => {
if override_path.exists() {
Some(override_path)
} else {
log::warn!(
"Project config override {} does not exist; using defaults until the file is created (e.g., by adding a word).",
override_path.display()
);
Some(override_path)
}
}
None => Self::find_project_config(start_dir),
};
if let Some(project_path) = project_path {
let project_config = WatchedFile::new(Some(project_path.clone()));
if project_path.exists() {
inner.project_config =
project_config.load(|path| Self::load_settings_from_file(path))?;
debug!("Loaded project config from {}", project_path.display());
} else {
inner.project_config = project_config;
}
} else {
info!("No project config found, using default");
let default_path = start_dir.join(USER_CONFIG_FILES[0]);
inner.project_config = WatchedFile::new(Some(default_path));
}
Self::rebuild_snapshot(&mut inner);
drop(inner);
Ok(config)
}
fn find_global_config_path() -> Option<PathBuf> {
if cfg!(unix) {
if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME")
&& !xdg_config_home.is_empty()
{
let path = PathBuf::from(xdg_config_home)
.join("codebook")
.join(GLOBAL_CONFIG_FILE);
return Some(path);
}
if let Some(home) = dirs::home_dir() {
let path = home
.join(".config")
.join("codebook")
.join(GLOBAL_CONFIG_FILE);
return Some(path);
}
}
if cfg!(windows)
&& let Some(config_dir) = dirs::config_dir()
{
return Some(config_dir.join("codebook").join(GLOBAL_CONFIG_FILE));
}
None
}
fn find_project_config(start_dir: &Path) -> Option<PathBuf> {
let config_files = USER_CONFIG_FILES;
let mut current_dir = Some(start_dir.to_path_buf());
while let Some(dir) = current_dir {
for config_name in &config_files {
let config_path = dir.join(config_name);
if config_path.is_file() {
return Some(config_path);
}
}
current_dir = dir.parent().map(PathBuf::from);
}
None
}
fn load_settings_from_file<P: AsRef<Path>>(path: P) -> Result<ConfigSettings, ConfigError> {
let path = path.as_ref();
let content = fs::read_to_string(path).map_err(|source| ConfigError::Read {
path: path.to_path_buf(),
source,
})?;
toml::from_str(&content).map_err(|source| ConfigError::Parse {
path: path.to_path_buf(),
source,
})
}
fn calculate_effective_settings(
project_config: &WatchedFile<ConfigSettings>,
global_config: &WatchedFile<ConfigSettings>,
) -> ConfigSettings {
let project = project_config
.content()
.cloned()
.unwrap_or_else(ConfigSettings::default);
if project.use_global {
if let Some(global) = global_config.content() {
let mut effective = global.clone();
effective.merge(project);
effective
} else {
project
}
} else {
project
}
}
fn snapshot(&self) -> Arc<ConfigSettings> {
self.inner.read().unwrap().snapshot.clone()
}
pub fn reload(&self) -> bool {
let mut inner = self.inner.write().unwrap();
let mut changed = false;
let (new_global, global_changed) = inner
.global_config
.clone()
.reload_if_changed(|path| Self::load_settings_from_file(path));
if global_changed {
debug!("Global config reloaded");
inner.global_config = new_global;
changed = true;
}
let (new_project, project_changed) = inner
.project_config
.clone()
.reload_if_changed(|path| Self::load_settings_from_file(path));
if project_changed {
debug!("Project config reloaded");
inner.project_config = new_project;
changed = true;
}
if changed {
Self::rebuild_snapshot(&mut inner);
}
changed
}
pub fn save(&self) -> Result<(), ConfigError> {
let mut inner = self.inner.write().unwrap();
let watched = &inner.project_config;
Self::save_watched(watched, "project")?;
inner.project_config = inner.project_config.clone().restamped();
Ok(())
}
pub fn save_global(&self) -> Result<(), ConfigError> {
let mut inner = self.inner.write().unwrap();
let watched = &inner.global_config;
Self::save_watched(watched, "global")?;
inner.global_config = inner.global_config.clone().restamped();
Ok(())
}
fn save_watched(watched: &WatchedFile<ConfigSettings>, label: &str) -> Result<(), ConfigError> {
let Some(path) = watched.path() else {
return Ok(());
};
let Some(settings) = watched.content() else {
return Ok(());
};
let content = toml::to_string_pretty(settings)?;
info!("Saving {label} configuration to {}", path.display());
let write_err = |source| ConfigError::Write {
path: path.to_path_buf(),
source,
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(write_err)?;
}
fs::write(path, content).map_err(write_err)
}
pub fn clean_cache(&self) {
let dir_path = self.cache_dir.clone();
if !dir_path.is_dir() {
return;
}
let path_str = dir_path.to_string_lossy();
if !path_str.contains(CACHE_DIR) {
log::error!(
"Cache directory path '{path_str}' doesn't contain '{CACHE_DIR}', refusing to clean"
);
return;
}
if let Ok(entries) = fs::read_dir(dir_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let _ = fs::remove_dir_all(path);
} else {
let _ = fs::remove_file(path);
}
}
}
}
pub fn project_config_path(&self) -> Option<PathBuf> {
self.inner
.read()
.unwrap()
.project_config
.path()
.map(|p| p.to_path_buf())
}
pub fn global_config_path(&self) -> Option<PathBuf> {
self.inner
.read()
.unwrap()
.global_config
.path()
.map(|p| p.to_path_buf())
}
fn rebuild_snapshot(inner: &mut ConfigInner) {
let effective =
Self::calculate_effective_settings(&inner.project_config, &inner.global_config);
inner.snapshot = Arc::new(effective);
}
fn update_project_settings<F>(&self, update: F) -> bool
where
F: FnOnce(&mut ConfigSettings) -> bool,
{
let mut inner = self.inner.write().unwrap();
let mut settings = inner
.project_config
.content()
.cloned()
.unwrap_or_else(ConfigSettings::default);
if !update(&mut settings) {
return false;
}
inner.project_config = inner.project_config.clone().with_content_value(settings);
Self::rebuild_snapshot(&mut inner);
true
}
fn update_global_settings<F>(&self, update: F) -> bool
where
F: FnOnce(&mut ConfigSettings) -> bool,
{
let mut inner = self.inner.write().unwrap();
let mut settings = inner
.global_config
.content()
.cloned()
.unwrap_or_else(ConfigSettings::default);
if !update(&mut settings) {
return false;
}
inner.global_config = inner.global_config.clone().with_content_value(settings);
Self::rebuild_snapshot(&mut inner);
true
}
}
impl CodebookConfig for CodebookConfigFile {
fn add_word(&self, word: &str) -> bool {
self.update_project_settings(|settings| settings.insert_word(word))
}
fn add_word_global(&self, word: &str) -> bool {
self.update_global_settings(|settings| settings.insert_word(word))
}
fn add_ignore(&self, file: &str) -> bool {
self.update_project_settings(|settings| settings.insert_ignore(file))
}
fn add_include(&self, file: &str) -> bool {
self.update_project_settings(|settings| settings.insert_include(file))
}
fn get_dictionary_ids(&self) -> Vec<String> {
let snapshot = self.snapshot();
snapshot.dictionary_ids()
}
fn should_include_path(&self, path: &Path) -> bool {
let snapshot = self.snapshot();
snapshot.should_include_path(path)
}
fn should_ignore_path(&self, path: &Path) -> bool {
let snapshot = self.snapshot();
snapshot.should_ignore_path(path)
}
fn is_allowed_word(&self, word: &str) -> bool {
let snapshot = self.snapshot();
snapshot.is_allowed_word(word)
}
fn should_flag_word(&self, word: &str) -> bool {
let snapshot = self.snapshot();
snapshot.should_flag_word(word)
}
fn get_ignore_patterns(&self) -> Vec<Regex> {
self.snapshot().ignore_patterns.clone()
}
fn get_min_word_length(&self) -> usize {
self.snapshot().min_word_length()
}
fn should_check_tag(&self, tag: &str) -> bool {
self.snapshot().should_check_tag(tag)
}
fn cache_dir(&self) -> &Path {
&self.cache_dir
}
fn resolve_for_file(&self, relative_path: &Path) -> Option<Arc<ConfigSettings>> {
let snapshot = self.snapshot();
if snapshot.overrides.is_empty() {
return None;
}
if !snapshot
.overrides
.iter()
.any(|o| o.matches_path(relative_path))
{
return None;
}
Some(Arc::new(snapshot.resolve_for_path(relative_path)))
}
}
#[derive(Debug)]
pub struct CodebookConfigMemory {
settings: RwLock<ConfigSettings>,
cache_dir: PathBuf,
}
impl Default for CodebookConfigMemory {
fn default() -> Self {
Self {
settings: RwLock::new(ConfigSettings::default()),
cache_dir: helpers::default_cache_dir(),
}
}
}
impl CodebookConfigMemory {
pub fn new(settings: ConfigSettings) -> Self {
Self {
settings: RwLock::new(settings),
cache_dir: helpers::default_cache_dir(),
}
}
}
impl CodebookConfigMemory {
fn snapshot(&self) -> Arc<ConfigSettings> {
Arc::new(self.settings.read().unwrap().clone())
}
}
impl CodebookConfig for CodebookConfigMemory {
fn add_word(&self, word: &str) -> bool {
let mut settings = self.settings.write().unwrap();
settings.insert_word(word)
}
fn add_word_global(&self, word: &str) -> bool {
self.add_word(word)
}
fn add_ignore(&self, file: &str) -> bool {
let mut settings = self.settings.write().unwrap();
settings.insert_ignore(file)
}
fn add_include(&self, file: &str) -> bool {
let mut settings = self.settings.write().unwrap();
settings.insert_include(file)
}
fn get_dictionary_ids(&self) -> Vec<String> {
let snapshot = self.snapshot();
snapshot.dictionary_ids()
}
fn should_include_path(&self, path: &Path) -> bool {
let snapshot = self.snapshot();
snapshot.should_include_path(path)
}
fn should_ignore_path(&self, path: &Path) -> bool {
let snapshot = self.snapshot();
snapshot.should_ignore_path(path)
}
fn is_allowed_word(&self, word: &str) -> bool {
let snapshot = self.snapshot();
snapshot.is_allowed_word(word)
}
fn should_flag_word(&self, word: &str) -> bool {
let snapshot = self.snapshot();
snapshot.should_flag_word(word)
}
fn get_ignore_patterns(&self) -> Vec<Regex> {
self.settings.read().unwrap().ignore_patterns.clone()
}
fn get_min_word_length(&self) -> usize {
self.snapshot().min_word_length()
}
fn should_check_tag(&self, tag: &str) -> bool {
self.snapshot().should_check_tag(tag)
}
fn cache_dir(&self) -> &Path {
&self.cache_dir
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
#[derive(Debug, Clone, Copy)]
pub enum ConfigType {
Project,
Global,
}
fn load_from_file<P: AsRef<Path>>(
config_type: ConfigType,
path: P,
) -> Result<CodebookConfigFile, io::Error> {
let config = CodebookConfigFile::default();
let mut inner = config.inner.write().unwrap();
if let Ok(settings) = CodebookConfigFile::load_settings_from_file(&path) {
let mut watched = WatchedFile::new(Some(path.as_ref().to_path_buf()));
watched = watched.with_content_value(settings);
match config_type {
ConfigType::Project => inner.project_config = watched,
ConfigType::Global => inner.global_config = watched,
}
CodebookConfigFile::rebuild_snapshot(&mut inner);
}
drop(inner);
Ok(config)
}
#[test]
fn test_save_global_creates_directories() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let global_dir = temp_dir.path().join("deep").join("nested").join("dir");
let config_path = global_dir.join("codebook.toml");
let config = CodebookConfigFile::default();
{
let mut inner = config.inner.write().unwrap();
let mut global_config = WatchedFile::new(Some(config_path.clone()));
global_config = global_config.with_content_value(ConfigSettings::default());
inner.global_config = global_config;
}
assert!(!global_dir.exists());
config.save_global()?;
assert!(global_dir.exists());
assert!(config_path.exists());
Ok(())
}
#[test]
fn test_add_word() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("codebook.toml");
let config = CodebookConfigFile::default();
{
let mut inner = config.inner.write().unwrap();
inner.project_config = WatchedFile::new(Some(config_path.clone()));
}
config.save()?;
config.add_word("testword");
config.save()?;
let loaded_config = load_from_file(ConfigType::Project, &config_path)?;
assert!(loaded_config.is_allowed_word("testword"));
Ok(())
}
#[test]
fn test_add_word_global() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("codebook.toml");
let config = CodebookConfigFile::default();
{
let mut inner = config.inner.write().unwrap();
let global_config = WatchedFile::new(Some(config_path.clone()));
inner.global_config = global_config.with_content_value(ConfigSettings::default());
}
config.save_global()?;
config.add_word_global("testword");
config.save_global()?;
let loaded_config = load_from_file(ConfigType::Global, &config_path)?;
assert!(loaded_config.is_allowed_word("testword"));
Ok(())
}
#[test]
fn test_ignore_patterns() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("codebook.toml");
let mut file = File::create(&config_path)?;
let a = r#"
ignore_patterns = [
"^[ATCG]+$",
"\\d{3}-\\d{2}-\\d{4}" # Social Security Number format
]
"#;
file.write_all(a.as_bytes())?;
let config = load_from_file(ConfigType::Project, &config_path)?;
let patterns = config.snapshot().ignore_patterns.clone();
assert!(patterns.iter().any(|p| p.as_str() == "^[ATCG]+$"));
assert!(
patterns
.iter()
.any(|p| p.as_str() == "\\d{3}-\\d{2}-\\d{4}")
);
let patterns = config.get_ignore_patterns();
assert!(patterns.len() == 2);
Ok(())
}
#[test]
fn test_reload_ignore_patterns() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("codebook.toml");
let mut file = File::create(&config_path)?;
write!(
file,
r#"
ignore_patterns = [
"^[ATCG]+$"
]
"#
)?;
let config = load_from_file(ConfigType::Project, &config_path)?;
assert!(config.get_ignore_patterns().len() == 1);
let mut file = File::create(&config_path)?;
let a = r#"
ignore_patterns = [
"^[ATCG]+$",
"\\d{3}-\\d{2}-\\d{4}"
]
"#;
file.write_all(a.as_bytes())?;
config.reload();
assert!(config.get_ignore_patterns().len() == 2);
let mut file = File::create(&config_path)?;
write!(
file,
r#"
ignore_patterns = []
"#
)?;
config.reload();
assert!(config.get_ignore_patterns().is_empty());
Ok(())
}
#[test]
fn test_config_recursive_search() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let sub_dir = temp_dir.path().join("sub");
let sub_sub_dir = sub_dir.join("subsub");
fs::create_dir_all(&sub_sub_dir)?;
let config_path = temp_dir.path().join("codebook.toml");
let mut file = File::create(&config_path)?;
write!(
file,
r#"
dictionaries = ["en_US"]
words = ["testword"]
flag_words = ["todo"]
ignore_paths = ["target/**/*"]
"#
)?;
let config = CodebookConfigFile::load_configs(&sub_sub_dir, None, None)?;
assert!(config.snapshot().words.contains(&"testword".to_string()));
assert_eq!(
config.project_config_path(),
Some(config_path.canonicalize()?)
);
Ok(())
}
#[test]
fn test_config_recursive_search_from_relative_dir() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let sub_dir = temp_dir.path().join("sub");
fs::create_dir_all(&sub_dir)?;
let config_path = temp_dir.path().join("codebook.toml");
fs::write(&config_path, r#"words = ["parentword"]"#)?;
let original_dir = env::current_dir()?;
env::set_current_dir(&sub_dir)?;
let result = CodebookConfigFile::load_configs(Path::new("."), None, None);
env::set_current_dir(original_dir)?;
let config = result?;
assert!(config.is_allowed_word("parentword"));
assert_eq!(
config.project_config_path(),
Some(temp_dir.path().canonicalize()?.join("codebook.toml"))
);
Ok(())
}
#[test]
fn test_global_config_override_is_used() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let workspace_dir = temp_dir.path().join("workspace");
fs::create_dir_all(&workspace_dir)?;
let custom_global_dir = temp_dir.path().join("global");
fs::create_dir_all(&custom_global_dir)?;
let override_path = custom_global_dir.join("codebook.toml");
fs::write(
&override_path,
r#"
words = ["customword"]
"#,
)?;
let config = CodebookConfigFile::load_with_overrides(
Some(workspace_dir.as_path()),
Some(override_path.clone()),
None,
)?;
assert_eq!(config.global_config_path(), Some(override_path));
assert!(config.is_allowed_word("customword"));
Ok(())
}
#[test]
fn test_project_config_override_is_used() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let workspace_dir = temp_dir.path().join("workspace");
fs::create_dir_all(&workspace_dir)?;
fs::write(
workspace_dir.join("codebook.toml"),
r#"words = ["rootword"]"#,
)?;
let nested_dir = workspace_dir.join("toolConfig");
fs::create_dir_all(&nested_dir)?;
let override_path = nested_dir.join("codebook.toml");
fs::write(&override_path, r#"words = ["nestedword"]"#)?;
let config = CodebookConfigFile::load_with_overrides(
Some(workspace_dir.as_path()),
None,
Some(override_path.clone()),
)?;
assert_eq!(config.project_config_path(), Some(override_path));
assert!(config.is_allowed_word("nestedword"));
assert!(!config.is_allowed_word("rootword"));
Ok(())
}
#[test]
fn test_project_config_override_missing_file_used_on_save() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let workspace_dir = temp_dir.path().join("workspace");
fs::create_dir_all(&workspace_dir)?;
fs::write(
workspace_dir.join("codebook.toml"),
r#"words = ["rootword"]"#,
)?;
let override_path = workspace_dir.join("toolConfig").join("codebook.toml");
let config = CodebookConfigFile::load_with_overrides(
Some(workspace_dir.as_path()),
None,
Some(override_path.clone()),
)?;
assert_eq!(config.project_config_path(), Some(override_path.clone()));
assert!(!override_path.exists());
assert!(!config.is_allowed_word("rootword"));
assert!(config.add_word("newword"));
config.save()?;
assert!(override_path.exists());
let written = fs::read_to_string(&override_path)?;
assert!(written.contains("newword"));
Ok(())
}
#[test]
fn test_should_ignore_path() {
let config = CodebookConfigFile::default();
{
let mut inner = config.inner.write().unwrap();
let mut settings = inner
.project_config
.content()
.cloned()
.unwrap_or_else(ConfigSettings::default);
settings.ignore_paths.push("target/**/*".to_string());
inner.project_config = inner.project_config.clone().with_content_value(settings);
let effective = CodebookConfigFile::calculate_effective_settings(
&inner.project_config,
&inner.global_config,
);
inner.snapshot = Arc::new(effective);
}
assert!(config.should_ignore_path("target/debug/build".as_ref()));
assert!(!config.should_ignore_path("src/main.rs".as_ref()));
}
#[test]
fn test_should_include_path() {
let config = CodebookConfigFile::default();
assert!(config.should_include_path("src/main.rs".as_ref()));
assert!(config.should_include_path("target/debug/build".as_ref()));
let config = CodebookConfigFile::default();
{
let mut inner = config.inner.write().unwrap();
let mut settings = inner
.project_config
.content()
.cloned()
.unwrap_or_else(ConfigSettings::default);
settings.include_paths.push("**/*.rs".to_string());
inner.project_config = inner.project_config.clone().with_content_value(settings);
let effective = CodebookConfigFile::calculate_effective_settings(
&inner.project_config,
&inner.global_config,
);
inner.snapshot = Arc::new(effective);
}
assert!(config.should_include_path("src/main.rs".as_ref()));
assert!(!config.should_include_path("src/main.py".as_ref()));
assert!(!config.should_include_path("README.md".as_ref()));
}
#[test]
fn test_reload() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("codebook.toml");
let config = CodebookConfigFile::default();
{
let mut inner = config.inner.write().unwrap();
inner.project_config = WatchedFile::new(Some(config_path.clone()));
}
config.save()?;
let mut file = File::create(&config_path)?;
write!(
file,
r#"
words = ["testword"]
"#
)?;
config.reload();
assert!(config.is_allowed_word("testword"));
Ok(())
}
#[test]
fn test_reload_when_deleted() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("codebook.toml");
let config = CodebookConfigFile::default();
{
let mut inner = config.inner.write().unwrap();
inner.project_config = WatchedFile::new(Some(config_path.clone()));
}
config.save()?;
let mut file = File::create(&config_path)?;
write!(
file,
r#"
words = ["testword"]
"#
)?;
config.reload();
assert!(config.is_allowed_word("testword"));
fs::remove_file(&config_path)?;
config.reload();
assert!(!config.is_allowed_word("testword"));
Ok(())
}
#[test]
fn test_add_word_case() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("codebook.toml");
let config = CodebookConfigFile::default();
{
let mut inner = config.inner.write().unwrap();
inner.project_config = WatchedFile::new(Some(config_path.clone()));
}
config.save()?;
config.add_word("TestWord");
config.save()?;
let loaded_config = load_from_file(ConfigType::Global, &config_path)?;
assert!(loaded_config.is_allowed_word("testword"));
assert!(loaded_config.is_allowed_word("TESTWORD"));
assert!(loaded_config.is_allowed_word("TestWord"));
Ok(())
}
#[test]
fn test_add_word_global_case() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("codebook.toml");
let config = CodebookConfigFile::default();
{
let mut inner = config.inner.write().unwrap();
let global_config = WatchedFile::new(Some(config_path.clone()));
inner.global_config = global_config.with_content_value(ConfigSettings::default());
}
config.save_global()?;
config.add_word_global("TestWord");
config.save_global()?;
let loaded_config = load_from_file(ConfigType::Global, &config_path)?;
assert!(loaded_config.is_allowed_word("testword"));
assert!(loaded_config.is_allowed_word("TESTWORD"));
assert!(loaded_config.is_allowed_word("TestWord"));
Ok(())
}
#[test]
fn test_global_and_project_config() -> Result<(), ConfigError> {
let global_temp = TempDir::new().unwrap();
let project_temp = TempDir::new().unwrap();
let global_config_dir = global_temp.path().join("codebook");
fs::create_dir_all(&global_config_dir)?;
let global_config_path = global_config_dir.join("codebook.toml");
let mut global_file = File::create(&global_config_path)?;
write!(
global_file,
r#"
dictionaries = ["en_US", "fr_FR"]
words = ["globalword1", "globalword2"]
flag_words = ["globaltodo"]
"#
)?;
let project_config_path = project_temp.path().join("codebook.toml");
let mut project_file = File::create(&project_config_path)?;
write!(
project_file,
r#"
words = ["projectword"]
flag_words = ["projecttodo"]
use_global = true
"#
)?;
let config = CodebookConfigFile::default();
{
let mut inner = config.inner.write().unwrap();
inner.global_config = WatchedFile::new(Some(global_config_path.clone()));
inner.project_config = WatchedFile::new(Some(project_config_path.clone()));
}
{
let mut inner = config.inner.write().unwrap();
if let Ok(global_settings) =
CodebookConfigFile::load_settings_from_file(&global_config_path)
{
inner.global_config = inner
.global_config
.clone()
.with_content_value(global_settings);
}
if let Ok(project_settings) =
CodebookConfigFile::load_settings_from_file(&project_config_path)
{
inner.project_config = inner
.project_config
.clone()
.with_content_value(project_settings);
}
let effective = CodebookConfigFile::calculate_effective_settings(
&inner.project_config,
&inner.global_config,
);
inner.snapshot = Arc::new(effective);
}
assert!(config.is_allowed_word("globalword1")); assert!(config.is_allowed_word("projectword")); assert!(config.should_flag_word("globaltodo")); assert!(config.should_flag_word("projecttodo"));
let dictionaries = config.get_dictionary_ids();
assert_eq!(dictionaries.len(), 2);
assert!(dictionaries.contains(&"en_us".to_string()));
assert!(dictionaries.contains(&"fr_fr".to_string()));
let mut project_file = File::create(config.project_config_path().unwrap())?;
write!(
project_file,
r#"
words = ["projectword"]
flag_words = ["projecttodo"]
use_global = false
"#
)?;
config.reload();
assert!(config.is_allowed_word("projectword")); assert!(!config.is_allowed_word("globalword1")); assert!(config.should_flag_word("projecttodo")); assert!(!config.should_flag_word("globaltodo"));
Ok(())
}
#[test]
fn test_resolve_for_file_no_overrides() {
let config = CodebookConfigFile::default();
{
let mut inner = config.inner.write().unwrap();
let settings = ConfigSettings {
words: vec!["base".to_string()],
..Default::default()
};
inner.project_config = inner.project_config.clone().with_content_value(settings);
CodebookConfigFile::rebuild_snapshot(&mut inner);
}
assert!(config.resolve_for_file(Path::new("src/main.rs")).is_none());
}
#[test]
fn test_resolve_for_file_with_matching_override() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("codebook.toml");
let mut file = File::create(&config_path)?;
write!(
file,
r#"
words = ["base"]
[[overrides]]
paths = ["**/*.md"]
extra_words = ["markdown"]
"#
)?;
let config = load_from_file(ConfigType::Project, &config_path)?;
let resolved = config.resolve_for_file(Path::new("README.md"));
assert!(resolved.is_some());
let settings = resolved.unwrap();
assert!(settings.is_allowed_word("base"));
assert!(settings.is_allowed_word("markdown"));
assert!(config.resolve_for_file(Path::new("src/main.rs")).is_none());
Ok(())
}
#[test]
fn test_resolve_for_file_global_and_project_overrides() -> Result<(), ConfigError> {
let global_temp = TempDir::new().unwrap();
let project_temp = TempDir::new().unwrap();
let global_config_path = global_temp.path().join("codebook.toml");
fs::write(
&global_config_path,
r#"
words = ["globalbase"]
[[overrides]]
paths = ["**/*.md"]
extra_words = ["fromglobal"]
"#,
)?;
let project_config_path = project_temp.path().join("codebook.toml");
fs::write(
&project_config_path,
r#"
words = ["projectbase"]
[[overrides]]
paths = ["**/*.md"]
extra_words = ["fromproject"]
"#,
)?;
let config = CodebookConfigFile::default();
{
let mut inner = config.inner.write().unwrap();
if let Ok(global_settings) =
CodebookConfigFile::load_settings_from_file(&global_config_path)
{
inner.global_config =
WatchedFile::new(Some(global_config_path)).with_content_value(global_settings);
}
if let Ok(project_settings) =
CodebookConfigFile::load_settings_from_file(&project_config_path)
{
inner.project_config = WatchedFile::new(Some(project_config_path))
.with_content_value(project_settings);
}
let effective = CodebookConfigFile::calculate_effective_settings(
&inner.project_config,
&inner.global_config,
);
inner.snapshot = Arc::new(effective);
}
let resolved = config.resolve_for_file(Path::new("docs/guide.md"));
assert!(resolved.is_some());
let settings = resolved.unwrap();
assert!(settings.is_allowed_word("globalbase"));
assert!(settings.is_allowed_word("projectbase"));
assert!(settings.is_allowed_word("fromglobal"));
assert!(settings.is_allowed_word("fromproject"));
Ok(())
}
#[test]
fn test_resolve_for_file_use_global_false_ignores_global_overrides() -> Result<(), ConfigError>
{
let global_temp = TempDir::new().unwrap();
let project_temp = TempDir::new().unwrap();
let global_config_path = global_temp.path().join("codebook.toml");
fs::write(
&global_config_path,
r#"
words = ["globalbase"]
[[overrides]]
paths = ["**/*.md"]
extra_words = ["fromglobal"]
"#,
)?;
let project_config_path = project_temp.path().join("codebook.toml");
fs::write(
&project_config_path,
r#"
words = ["projectbase"]
use_global = false
[[overrides]]
paths = ["**/*.md"]
extra_words = ["fromproject"]
"#,
)?;
let config = CodebookConfigFile::default();
{
let mut inner = config.inner.write().unwrap();
if let Ok(global_settings) =
CodebookConfigFile::load_settings_from_file(&global_config_path)
{
inner.global_config =
WatchedFile::new(Some(global_config_path)).with_content_value(global_settings);
}
if let Ok(project_settings) =
CodebookConfigFile::load_settings_from_file(&project_config_path)
{
inner.project_config = WatchedFile::new(Some(project_config_path))
.with_content_value(project_settings);
}
let effective = CodebookConfigFile::calculate_effective_settings(
&inner.project_config,
&inner.global_config,
);
inner.snapshot = Arc::new(effective);
}
let resolved = config.resolve_for_file(Path::new("README.md"));
assert!(resolved.is_some());
let settings = resolved.unwrap();
assert!(settings.is_allowed_word("projectbase"));
assert!(settings.is_allowed_word("fromproject"));
assert!(!settings.is_allowed_word("globalbase"));
assert!(!settings.is_allowed_word("fromglobal"));
Ok(())
}
#[test]
fn test_save_preserves_overrides() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("codebook.toml");
fs::write(
&config_path,
r#"
words = ["base"]
[[overrides]]
paths = ["**/*.md"]
extra_words = ["markdown"]
"#,
)?;
let config = load_from_file(ConfigType::Project, &config_path)?;
config.add_word("newword");
config.save()?;
let reloaded = load_from_file(ConfigType::Project, &config_path)?;
assert!(reloaded.is_allowed_word("base"));
assert!(reloaded.is_allowed_word("newword"));
let resolved = reloaded.resolve_for_file(Path::new("README.md"));
assert!(resolved.is_some());
assert!(resolved.unwrap().is_allowed_word("markdown"));
Ok(())
}
#[test]
fn test_reload_picks_up_override_changes() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("codebook.toml");
fs::write(
&config_path,
r#"
words = ["base"]
"#,
)?;
let config = load_from_file(ConfigType::Project, &config_path)?;
assert!(config.resolve_for_file(Path::new("README.md")).is_none());
fs::write(
&config_path,
r#"
words = ["base"]
[[overrides]]
paths = ["**/*.md"]
extra_words = ["markdown"]
"#,
)?;
config.reload();
let resolved = config.resolve_for_file(Path::new("README.md"));
assert!(resolved.is_some());
assert!(resolved.unwrap().is_allowed_word("markdown"));
Ok(())
}
#[cfg(not(windows))]
#[test]
fn test_tilde_in_global_override_is_expanded_at_load() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let config = CodebookConfigFile::load_with_overrides(
Some(temp_dir.path()),
Some(PathBuf::from("~/nonexistent-codebook-test/codebook.toml")),
None,
)?;
let global_path = config.global_config_path().unwrap();
let home = dirs::home_dir().unwrap();
assert!(
global_path.starts_with(&home),
"expected {} to start with {}",
global_path.display(),
home.display()
);
Ok(())
}
#[test]
fn test_load_fails_on_invalid_config() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("codebook.toml");
fs::write(&config_path, r#"words = [invalid !!! toml"#).unwrap();
let result = CodebookConfigFile::load_with_overrides(
Some(temp_dir.path()),
Some(temp_dir.path().join("global.toml")),
None,
);
let err = result.expect_err("invalid config should fail to load");
assert!(matches!(err, ConfigError::Parse { .. }));
assert!(err.to_string().contains("codebook.toml"));
}
#[test]
fn test_load_fails_on_invalid_ignore_pattern() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("codebook.toml");
fs::write(&config_path, r#"ignore_patterns = ["[invalid"]"#).unwrap();
let result = CodebookConfigFile::load_with_overrides(
Some(temp_dir.path()),
Some(temp_dir.path().join("global.toml")),
None,
);
let err = result.expect_err("invalid regex should fail to load");
assert!(matches!(err, ConfigError::Parse { .. }));
assert!(err.to_string().contains("invalid regex pattern '[invalid'"));
}
#[test]
fn test_reload_keeps_config_on_parse_error() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("codebook.toml");
fs::write(&config_path, r#"words = ["frobnicate"]"#)?;
let config = CodebookConfigFile::load_with_overrides(
Some(temp_dir.path()),
Some(temp_dir.path().join("global.toml")),
None,
)?;
assert!(config.is_allowed_word("frobnicate"));
fs::write(&config_path, r#"words = [invalid !!! toml"#)?;
assert!(!config.reload(), "parse error should not count as a change");
assert!(
config.is_allowed_word("frobnicate"),
"last good config should survive a parse error"
);
fs::write(&config_path, r#"words = ["frobnicate", "fixed"]"#)?;
assert!(config.reload());
assert!(config.is_allowed_word("fixed"));
Ok(())
}
#[test]
fn test_save_does_not_trigger_spurious_reload() -> Result<(), ConfigError> {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("codebook.toml");
fs::write(&config_path, r#"words = ["zebra"]"#)?;
let config = CodebookConfigFile::load_with_overrides(
Some(temp_dir.path()),
Some(temp_dir.path().join("global.toml")),
None,
)?;
config.add_word("frobnicate");
config.save()?;
assert!(!config.reload());
assert!(config.is_allowed_word("frobnicate"));
assert!(config.is_allowed_word("zebra"));
Ok(())
}
}