codebook_config/
lib.rs

1mod helpers;
2mod settings;
3mod watched_file;
4use crate::helpers::expand_tilde;
5use crate::settings::ConfigSettings;
6use crate::watched_file::WatchedFile;
7use log::debug;
8use log::info;
9use regex::Regex;
10use std::env;
11use std::fmt::Debug;
12use std::fs;
13use std::io;
14use std::io::ErrorKind;
15use std::path::{Path, PathBuf};
16use std::sync::{Arc, RwLock};
17
18static CACHE_DIR: &str = "codebook";
19static GLOBAL_CONFIG_FILE: &str = "codebook.toml";
20static USER_CONFIG_FILES: [&str; 2] = ["codebook.toml", ".codebook.toml"];
21
22/// The main trait for Codebook configuration.
23pub trait CodebookConfig: Sync + Send + Debug {
24    fn add_word(&self, word: &str) -> Result<bool, io::Error>;
25    fn add_word_global(&self, word: &str) -> Result<bool, io::Error>;
26    fn add_ignore(&self, file: &str) -> Result<bool, io::Error>;
27    fn get_dictionary_ids(&self) -> Vec<String>;
28    fn should_ignore_path(&self, path: &Path) -> bool;
29    fn is_allowed_word(&self, word: &str) -> bool;
30    fn should_flag_word(&self, word: &str) -> bool;
31    fn get_ignore_patterns(&self) -> Option<Vec<Regex>>;
32    fn get_min_word_length(&self) -> usize;
33    fn cache_dir(&self) -> &Path;
34}
35
36/// Internal mutable state
37#[derive(Debug)]
38struct ConfigInner {
39    /// Project-specific config file watcher
40    project_config: WatchedFile<ConfigSettings>,
41    /// Global config file watcher
42    global_config: WatchedFile<ConfigSettings>,
43    /// Current snapshot
44    snapshot: Arc<ConfigSettings>,
45    /// Compiled regex patterns cache
46    regex_cache: Option<Vec<Regex>>,
47}
48
49#[derive(Debug)]
50pub struct CodebookConfigFile {
51    /// Single lock protecting all mutable state
52    inner: RwLock<ConfigInner>,
53    /// Directory for caching
54    pub cache_dir: PathBuf,
55}
56
57impl Default for CodebookConfigFile {
58    fn default() -> Self {
59        let inner = ConfigInner {
60            project_config: WatchedFile::new(None),
61            global_config: WatchedFile::new(None),
62            snapshot: Arc::new(ConfigSettings::default()),
63            regex_cache: None,
64        };
65
66        Self {
67            inner: RwLock::new(inner),
68            cache_dir: helpers::default_cache_dir(),
69        }
70    }
71}
72
73impl CodebookConfigFile {
74    /// Load configuration by searching for both global and project-specific configs
75    pub fn load(current_dir: Option<&Path>) -> Result<Self, io::Error> {
76        Self::load_with_global_config(current_dir, None)
77    }
78
79    /// Load configuration with an explicit global config override.
80    pub fn load_with_global_config(
81        current_dir: Option<&Path>,
82        global_config_path: Option<PathBuf>,
83    ) -> Result<Self, io::Error> {
84        debug!("Initializing CodebookConfig");
85
86        if let Some(current_dir) = current_dir {
87            let current_dir = Path::new(current_dir);
88            Self::load_configs(current_dir, global_config_path)
89        } else {
90            let current_dir = env::current_dir()?;
91            Self::load_configs(&current_dir, global_config_path)
92        }
93    }
94
95    /// Load both global and project configuration
96    fn load_configs(
97        start_dir: &Path,
98        global_config_override: Option<PathBuf>,
99    ) -> Result<Self, io::Error> {
100        let config = Self::default();
101        let mut inner = config.inner.write().unwrap();
102
103        // First, try to load global config
104        let global_config_path = match global_config_override {
105            Some(path) => Some(path.to_path_buf()),
106            None => Self::find_global_config_path(),
107        };
108
109        if let Some(global_path) = global_config_path {
110            let global_config = WatchedFile::new(Some(global_path.clone()));
111
112            if global_path.exists() {
113                inner.global_config = global_config
114                    .load(|path| {
115                        Self::load_settings_from_file(path)
116                            .map_err(|e| format!("Failed to load global config: {}", e))
117                    })
118                    .unwrap_or_else(|e| {
119                        debug!("{}", e);
120                        WatchedFile::new(Some(global_path.clone()))
121                    });
122                debug!("Loaded global config from {}", global_path.display());
123            } else {
124                info!("No global config found, using default");
125                inner.global_config = global_config;
126            }
127        }
128
129        // Then try to find and load project config
130        if let Some(project_path) = Self::find_project_config(start_dir)? {
131            debug!("Found project config at {}", project_path.display());
132            let project_config = WatchedFile::new(Some(project_path.clone()));
133
134            inner.project_config = project_config
135                .load(|path| {
136                    Self::load_settings_from_file(path)
137                        .map_err(|e| format!("Failed to load project config: {}", e))
138                })
139                .unwrap_or_else(|e| {
140                    debug!("{}", e);
141                    WatchedFile::new(Some(project_path.clone()))
142                });
143
144            debug!("Loaded project config from {}", project_path.display());
145        } else {
146            info!("No project config found, using default");
147            // Set path to start_dir if no config is found
148            let default_path = start_dir.join(USER_CONFIG_FILES[0]);
149            inner.project_config = WatchedFile::new(Some(default_path));
150        }
151
152        // Calculate initial effective settings
153        let effective =
154            Self::calculate_effective_settings(&inner.project_config, &inner.global_config);
155        inner.snapshot = Arc::new(effective);
156
157        drop(inner);
158        Ok(config)
159    }
160    /// Find the platform-specific global config directory and file path
161    fn find_global_config_path() -> Option<PathBuf> {
162        // On Linux/macOS XDG_CONFIG_HOME, fallback to ~/.config
163        if cfg!(unix) {
164            // First try XDG_CONFIG_HOME environment variable (Linux/macOS)
165            if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME") {
166                let path = PathBuf::from(xdg_config_home)
167                    .join("codebook")
168                    .join(GLOBAL_CONFIG_FILE);
169                return Some(path);
170            }
171            if let Some(home) = dirs::home_dir() {
172                let path = home
173                    .join(".config")
174                    .join("codebook")
175                    .join(GLOBAL_CONFIG_FILE);
176                return Some(path);
177            }
178        }
179
180        // On Windows, use dirs::config_dir() (typically %APPDATA%)
181        if cfg!(windows)
182            && let Some(config_dir) = dirs::config_dir()
183        {
184            return Some(config_dir.join("codebook").join(GLOBAL_CONFIG_FILE));
185        }
186
187        None
188    }
189
190    /// Find project configuration by searching up from the current directory
191    fn find_project_config(start_dir: &Path) -> Result<Option<PathBuf>, io::Error> {
192        let config_files = USER_CONFIG_FILES;
193
194        // Start from the given directory and walk up to root
195        let mut current_dir = Some(start_dir.to_path_buf());
196
197        while let Some(dir) = current_dir {
198            // Try each possible config filename in the current directory
199            for config_name in &config_files {
200                let config_path = dir.join(config_name);
201                if config_path.is_file() {
202                    return Ok(Some(config_path));
203                }
204            }
205
206            // Move to parent directory
207            current_dir = dir.parent().map(PathBuf::from);
208        }
209
210        Ok(None)
211    }
212
213    /// Load settings from a file
214    fn load_settings_from_file<P: AsRef<Path>>(path: P) -> Result<ConfigSettings, io::Error> {
215        let path = path.as_ref();
216        let content = fs::read_to_string(path)?;
217
218        match toml::from_str(&content) {
219            Ok(settings) => Ok(settings),
220            Err(e) => {
221                let err = io::Error::new(
222                    ErrorKind::InvalidData,
223                    format!("Failed to parse config file {}: {e}", path.display()),
224                );
225                Err(err)
226            }
227        }
228    }
229
230    /// Calculate the effective settings based on global and project settings
231    fn calculate_effective_settings(
232        project_config: &WatchedFile<ConfigSettings>,
233        global_config: &WatchedFile<ConfigSettings>,
234    ) -> ConfigSettings {
235        let project = project_config
236            .content()
237            .cloned()
238            .unwrap_or_else(ConfigSettings::default);
239
240        if project.use_global {
241            if let Some(global) = global_config.content() {
242                let mut effective = global.clone();
243                effective.merge(project);
244                effective
245            } else {
246                project
247            }
248        } else {
249            project
250        }
251    }
252
253    /// Get current configuration snapshot (cheap to clone)
254    fn snapshot(&self) -> Arc<ConfigSettings> {
255        self.inner.read().unwrap().snapshot.clone()
256    }
257
258    /// Reload both global and project configurations, only reading files if they've changed
259    pub fn reload(&self) -> Result<bool, io::Error> {
260        let mut inner = self.inner.write().unwrap();
261        let mut changed = false;
262
263        // Check and reload global config if changed
264        let (new_global, global_changed) = inner
265            .global_config
266            .clone()
267            .reload_if_changed(|path| {
268                Self::load_settings_from_file(path).map_err(|e| e.to_string())
269            })
270            .unwrap_or_else(|e| {
271                debug!("Failed to reload global config: {}", e);
272                (inner.global_config.clone(), false)
273            });
274
275        if global_changed {
276            debug!("Global config reloaded");
277            inner.global_config = new_global;
278            changed = true;
279        }
280
281        // Check and reload project config if changed
282        let (new_project, project_changed) = inner
283            .project_config
284            .clone()
285            .reload_if_changed(|path| {
286                Self::load_settings_from_file(path).map_err(|e| e.to_string())
287            })
288            .unwrap_or_else(|e| {
289                debug!("Failed to reload project config: {}", e);
290                (inner.project_config.clone(), false)
291            });
292
293        if project_changed {
294            debug!("Project config reloaded");
295            inner.project_config = new_project;
296            changed = true;
297        }
298
299        // Recalculate effective settings if anything changed
300        if changed {
301            let effective =
302                Self::calculate_effective_settings(&inner.project_config, &inner.global_config);
303            inner.snapshot = Arc::new(effective);
304            inner.regex_cache = None; // Invalidate regex cache
305        }
306
307        Ok(changed)
308    }
309
310    /// Save the project configuration to its file
311    pub fn save(&self) -> Result<(), io::Error> {
312        let inner = self.inner.read().unwrap();
313
314        let project_config_path = match inner.project_config.path() {
315            Some(path) => path.to_path_buf(),
316            None => return Ok(()),
317        };
318
319        let settings = match inner.project_config.content() {
320            Some(settings) => settings,
321            None => return Ok(()),
322        };
323
324        let content = toml::to_string_pretty(settings).map_err(io::Error::other)?;
325        info!(
326            "Saving project configuration to {}",
327            project_config_path.display()
328        );
329        fs::write(&project_config_path, content)
330    }
331
332    /// Save the global configuration to its file
333    pub fn save_global(&self) -> Result<(), io::Error> {
334        let inner = self.inner.read().unwrap();
335
336        let global_config_path = match inner.global_config.path() {
337            Some(path) => path.to_path_buf(),
338            None => return Ok(()),
339        };
340
341        #[cfg(not(windows))]
342        let global_config_path = match expand_tilde(&global_config_path) {
343            Some(p) => p,
344            None => {
345                return Err(io::Error::new(
346                    io::ErrorKind::InvalidInput,
347                    format!(
348                        "Failed to expand tilde in path: {}",
349                        global_config_path.display()
350                    ),
351                ));
352            }
353        };
354
355        let settings = match inner.global_config.content() {
356            Some(settings) => settings,
357            None => return Ok(()),
358        };
359
360        let content = toml::to_string_pretty(settings).map_err(io::Error::other)?;
361        info!(
362            "Saving global configuration to {}",
363            global_config_path.display()
364        );
365        // Create parent directories if they don't exist
366        if let Some(parent) = global_config_path.parent() {
367            fs::create_dir_all(parent)?;
368        }
369        fs::write(&global_config_path, content)
370    }
371    /// Clean the cache directory
372    pub fn clean_cache(&self) {
373        let dir_path = self.cache_dir.clone();
374        // Check if the path exists and is a directory
375        if !dir_path.is_dir() {
376            return;
377        }
378
379        // Safety check: Ensure CACHE_DIR is in the path
380        let path_str = dir_path.to_string_lossy();
381        if !path_str.contains(CACHE_DIR) {
382            log::error!(
383                "Cache directory path '{path_str}' doesn't contain '{CACHE_DIR}', refusing to clean"
384            );
385            return;
386        }
387
388        // Read directory entries
389        if let Ok(entries) = fs::read_dir(dir_path) {
390            for entry in entries.flatten() {
391                let path = entry.path();
392
393                if path.is_dir() {
394                    // If it's a directory, recursively remove it
395                    let _ = fs::remove_dir_all(path);
396                } else {
397                    // If it's a file, remove it
398                    let _ = fs::remove_file(path);
399                }
400            }
401        }
402    }
403
404    /// Get path to project config if it exists
405    pub fn project_config_path(&self) -> Option<PathBuf> {
406        self.inner
407            .read()
408            .unwrap()
409            .project_config
410            .path()
411            .map(|p| p.to_path_buf())
412    }
413
414    /// Get path to global config if it exists
415    pub fn global_config_path(&self) -> Option<PathBuf> {
416        self.inner
417            .read()
418            .unwrap()
419            .global_config
420            .path()
421            .map(|p| p.to_path_buf())
422    }
423
424    fn rebuild_snapshot(inner: &mut ConfigInner) {
425        let effective =
426            Self::calculate_effective_settings(&inner.project_config, &inner.global_config);
427        inner.snapshot = Arc::new(effective);
428        inner.regex_cache = None;
429    }
430
431    fn update_project_settings<F>(&self, update: F) -> bool
432    where
433        F: FnOnce(&mut ConfigSettings) -> bool,
434    {
435        let mut inner = self.inner.write().unwrap();
436        let mut settings = inner
437            .project_config
438            .content()
439            .cloned()
440            .unwrap_or_else(ConfigSettings::default);
441
442        if !update(&mut settings) {
443            return false;
444        }
445
446        inner.project_config = inner.project_config.clone().with_content_value(settings);
447        Self::rebuild_snapshot(&mut inner);
448        true
449    }
450
451    fn update_global_settings<F>(&self, update: F) -> bool
452    where
453        F: FnOnce(&mut ConfigSettings) -> bool,
454    {
455        let mut inner = self.inner.write().unwrap();
456        let mut settings = inner
457            .global_config
458            .content()
459            .cloned()
460            .unwrap_or_else(ConfigSettings::default);
461
462        if !update(&mut settings) {
463            return false;
464        }
465
466        inner.global_config = inner.global_config.clone().with_content_value(settings);
467        Self::rebuild_snapshot(&mut inner);
468        true
469    }
470}
471
472impl CodebookConfig for CodebookConfigFile {
473    /// Add a word to the project configs allowlist
474    fn add_word(&self, word: &str) -> Result<bool, io::Error> {
475        Ok(self.update_project_settings(|settings| helpers::insert_word(settings, word)))
476    }
477    /// Add a word to the global configs allowlist
478    fn add_word_global(&self, word: &str) -> Result<bool, io::Error> {
479        Ok(self.update_global_settings(|settings| helpers::insert_word(settings, word)))
480    }
481
482    /// Add a file to the ignore list
483    fn add_ignore(&self, file: &str) -> Result<bool, io::Error> {
484        Ok(self.update_project_settings(|settings| helpers::insert_ignore(settings, file)))
485    }
486
487    /// Get dictionary IDs from effective configuration
488    fn get_dictionary_ids(&self) -> Vec<String> {
489        let snapshot = self.snapshot();
490        helpers::dictionary_ids(&snapshot)
491    }
492
493    /// Check if a path should be ignored based on the effective configuration
494    fn should_ignore_path(&self, path: &Path) -> bool {
495        let snapshot = self.snapshot();
496        helpers::should_ignore_path(&snapshot, path)
497    }
498
499    /// Check if a word is in the effective allowlist
500    fn is_allowed_word(&self, word: &str) -> bool {
501        let snapshot = self.snapshot();
502        helpers::is_allowed_word(&snapshot, word)
503    }
504
505    /// Check if a word should be flagged according to effective configuration
506    fn should_flag_word(&self, word: &str) -> bool {
507        let snapshot = self.snapshot();
508        helpers::should_flag_word(&snapshot, word)
509    }
510
511    /// Get the list of user-defined ignore patterns
512    fn get_ignore_patterns(&self) -> Option<Vec<Regex>> {
513        let mut inner = self.inner.write().unwrap();
514        if inner.regex_cache.is_none() {
515            let regex_set = helpers::build_ignore_regexes(&inner.snapshot.ignore_patterns);
516            inner.regex_cache = Some(regex_set);
517        }
518
519        inner.regex_cache.clone()
520    }
521
522    /// Get the minimum word length which should be checked
523    fn get_min_word_length(&self) -> usize {
524        helpers::min_word_length(&self.snapshot())
525    }
526
527    fn cache_dir(&self) -> &Path {
528        &self.cache_dir
529    }
530}
531
532#[derive(Debug)]
533pub struct CodebookConfigMemory {
534    settings: RwLock<ConfigSettings>,
535    cache_dir: PathBuf,
536}
537
538impl Default for CodebookConfigMemory {
539    fn default() -> Self {
540        Self {
541            settings: RwLock::new(ConfigSettings::default()),
542            cache_dir: helpers::default_cache_dir(),
543        }
544    }
545}
546
547impl CodebookConfigMemory {
548    pub fn new(settings: ConfigSettings) -> Self {
549        Self {
550            settings: RwLock::new(settings),
551            cache_dir: helpers::default_cache_dir(),
552        }
553    }
554}
555
556impl CodebookConfigMemory {
557    /// Get current configuration snapshot (cheap to clone)
558    fn snapshot(&self) -> Arc<ConfigSettings> {
559        Arc::new(self.settings.read().unwrap().clone())
560    }
561}
562
563impl CodebookConfig for CodebookConfigMemory {
564    fn add_word(&self, word: &str) -> Result<bool, io::Error> {
565        let mut settings = self.settings.write().unwrap();
566        Ok(helpers::insert_word(&mut settings, word))
567    }
568
569    fn add_word_global(&self, word: &str) -> Result<bool, io::Error> {
570        self.add_word(word)
571    }
572
573    fn add_ignore(&self, file: &str) -> Result<bool, io::Error> {
574        let mut settings = self.settings.write().unwrap();
575        Ok(helpers::insert_ignore(&mut settings, file))
576    }
577
578    fn get_dictionary_ids(&self) -> Vec<String> {
579        let snapshot = self.snapshot();
580        helpers::dictionary_ids(&snapshot)
581    }
582
583    fn should_ignore_path(&self, path: &Path) -> bool {
584        let snapshot = self.snapshot();
585        helpers::should_ignore_path(&snapshot, path)
586    }
587
588    fn is_allowed_word(&self, word: &str) -> bool {
589        let snapshot = self.snapshot();
590        helpers::is_allowed_word(&snapshot, word)
591    }
592
593    fn should_flag_word(&self, word: &str) -> bool {
594        let snapshot = self.snapshot();
595        helpers::should_flag_word(&snapshot, word)
596    }
597
598    fn get_ignore_patterns(&self) -> Option<Vec<Regex>> {
599        let snapshot = self.snapshot();
600        Some(helpers::build_ignore_regexes(&snapshot.ignore_patterns))
601    }
602
603    fn get_min_word_length(&self) -> usize {
604        helpers::min_word_length(&self.snapshot())
605    }
606
607    fn cache_dir(&self) -> &Path {
608        &self.cache_dir
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use std::fs::File;
616    use std::io::Write;
617    use tempfile::TempDir;
618
619    #[derive(Debug, Clone, Copy)]
620    pub enum ConfigType {
621        Project,
622        Global,
623    }
624
625    // Helper function for tests
626    fn load_from_file<P: AsRef<Path>>(
627        config_type: ConfigType,
628        path: P,
629    ) -> Result<CodebookConfigFile, io::Error> {
630        let config = CodebookConfigFile::default();
631        let mut inner = config.inner.write().unwrap();
632
633        match config_type {
634            ConfigType::Project => {
635                if let Ok(settings) = CodebookConfigFile::load_settings_from_file(&path) {
636                    let mut project_config = WatchedFile::new(Some(path.as_ref().to_path_buf()));
637                    project_config = project_config.with_content_value(settings);
638                    inner.project_config = project_config;
639
640                    // Recalculate effective settings
641                    let effective = CodebookConfigFile::calculate_effective_settings(
642                        &inner.project_config,
643                        &inner.global_config,
644                    );
645                    inner.snapshot = Arc::new(effective);
646                }
647            }
648            ConfigType::Global => {
649                if let Ok(settings) = CodebookConfigFile::load_settings_from_file(&path) {
650                    let mut global_config = WatchedFile::new(Some(path.as_ref().to_path_buf()));
651                    global_config = global_config.with_content_value(settings);
652                    inner.global_config = global_config;
653
654                    // Recalculate effective settings
655                    let effective = CodebookConfigFile::calculate_effective_settings(
656                        &inner.project_config,
657                        &inner.global_config,
658                    );
659                    inner.snapshot = Arc::new(effective);
660                }
661            }
662        }
663
664        drop(inner);
665        Ok(config)
666    }
667
668    #[test]
669    fn test_save_global_creates_directories() -> Result<(), io::Error> {
670        let temp_dir = TempDir::new().unwrap();
671        let global_dir = temp_dir.path().join("deep").join("nested").join("dir");
672        let config_path = global_dir.join("codebook.toml");
673
674        // Create config with a path that doesn't exist yet
675        // Create a config with the global path set
676        let config = CodebookConfigFile::default();
677        {
678            let mut inner = config.inner.write().unwrap();
679            let mut global_config = WatchedFile::new(Some(config_path.clone()));
680            global_config = global_config.with_content_value(ConfigSettings::default());
681            inner.global_config = global_config;
682        }
683
684        // Directory doesn't exist yet
685        assert!(!global_dir.exists());
686
687        // Save should create directories
688        config.save_global()?;
689
690        // Now directory and file should exist
691        assert!(global_dir.exists());
692        assert!(config_path.exists());
693
694        Ok(())
695    }
696
697    #[test]
698    fn test_add_word() -> Result<(), io::Error> {
699        let temp_dir = TempDir::new().unwrap();
700        let config_path = temp_dir.path().join("codebook.toml");
701
702        // Create initial config
703        // Create a config with the project path set
704        let config = CodebookConfigFile::default();
705        {
706            let mut inner = config.inner.write().unwrap();
707            inner.project_config = WatchedFile::new(Some(config_path.clone()));
708        }
709        config.save()?;
710
711        // Add a word
712        config.add_word("testword")?;
713        config.save()?;
714
715        // Reload config and verify
716        let loaded_config = load_from_file(ConfigType::Project, &config_path)?;
717        assert!(loaded_config.is_allowed_word("testword"));
718
719        Ok(())
720    }
721
722    #[test]
723    fn test_add_word_global() -> Result<(), io::Error> {
724        let temp_dir = TempDir::new().unwrap();
725        let config_path = temp_dir.path().join("codebook.toml");
726
727        // Create initial config
728        // Create a config with the global path set
729        let config = CodebookConfigFile::default();
730        {
731            let mut inner = config.inner.write().unwrap();
732            let global_config = WatchedFile::new(Some(config_path.clone()));
733            inner.global_config = global_config.with_content_value(ConfigSettings::default());
734        }
735        config.save_global()?;
736
737        // Add a word
738        config.add_word_global("testword")?;
739        config.save_global()?;
740
741        // Reload config and verify
742        let loaded_config = load_from_file(ConfigType::Global, &config_path)?;
743        assert!(loaded_config.is_allowed_word("testword"));
744
745        Ok(())
746    }
747
748    #[test]
749    fn test_ignore_patterns() -> Result<(), io::Error> {
750        let temp_dir = TempDir::new().unwrap();
751        let config_path = temp_dir.path().join("codebook.toml");
752        let mut file = File::create(&config_path)?;
753        let a = r#"
754        ignore_patterns = [
755            "^[ATCG]+$",
756            "\\d{3}-\\d{2}-\\d{4}"  # Social Security Number format
757        ]
758        "#;
759        file.write_all(a.as_bytes())?;
760
761        let config = load_from_file(ConfigType::Project, &config_path)?;
762        let patterns = config.snapshot().ignore_patterns.clone();
763        assert!(patterns.contains(&String::from("^[ATCG]+$")));
764        assert!(patterns.contains(&String::from("\\d{3}-\\d{2}-\\d{4}")));
765        let reg = config.get_ignore_patterns();
766
767        let patterns = reg.as_ref().unwrap();
768        assert!(patterns.len() == 2);
769        Ok(())
770    }
771
772    #[test]
773    fn test_reload_ignore_patterns() -> Result<(), io::Error> {
774        let temp_dir = TempDir::new().unwrap();
775        let config_path = temp_dir.path().join("codebook.toml");
776
777        // Create initial config with DNA pattern
778        let mut file = File::create(&config_path)?;
779        write!(
780            file,
781            r#"
782            ignore_patterns = [
783                "^[ATCG]+$"
784            ]
785            "#
786        )?;
787
788        let config = load_from_file(ConfigType::Project, &config_path)?;
789        assert!(config.get_ignore_patterns().unwrap().len() == 1);
790
791        // Update config with new pattern
792        let mut file = File::create(&config_path)?;
793        let a = r#"
794        ignore_patterns = [
795            "^[ATCG]+$",
796            "\\d{3}-\\d{2}-\\d{4}"
797        ]
798        "#;
799        file.write_all(a.as_bytes())?;
800
801        // Reload and verify both patterns work
802        config.reload()?;
803        assert!(config.get_ignore_patterns().unwrap().len() == 2);
804
805        // Update config to remove all patterns
806        let mut file = File::create(&config_path)?;
807        write!(
808            file,
809            r#"
810            ignore_patterns = []
811            "#
812        )?;
813
814        // Reload and verify no patterns match
815        config.reload()?;
816        assert!(config.get_ignore_patterns().unwrap().is_empty());
817
818        Ok(())
819    }
820
821    #[test]
822    fn test_config_recursive_search() -> Result<(), io::Error> {
823        let temp_dir = TempDir::new().unwrap();
824        let sub_dir = temp_dir.path().join("sub");
825        let sub_sub_dir = sub_dir.join("subsub");
826        fs::create_dir_all(&sub_sub_dir)?;
827
828        let config_path = temp_dir.path().join("codebook.toml");
829        let mut file = File::create(&config_path)?;
830        write!(
831            file,
832            r#"
833            dictionaries = ["en_US"]
834            words = ["testword"]
835            flag_words = ["todo"]
836            ignore_paths = ["target/**/*"]
837            "#
838        )?;
839
840        let config = CodebookConfigFile::load_configs(&sub_sub_dir, None)?;
841        assert!(config.snapshot().words.contains(&"testword".to_string()));
842
843        // Check that the config file path is stored
844        assert_eq!(config.project_config_path(), Some(config_path));
845        Ok(())
846    }
847
848    #[test]
849    fn test_global_config_override_is_used() -> Result<(), io::Error> {
850        let temp_dir = TempDir::new().unwrap();
851        let workspace_dir = temp_dir.path().join("workspace");
852        fs::create_dir_all(&workspace_dir)?;
853        let custom_global_dir = temp_dir.path().join("global");
854        fs::create_dir_all(&custom_global_dir)?;
855        let override_path = custom_global_dir.join("codebook.toml");
856
857        fs::write(
858            &override_path,
859            r#"
860            words = ["customword"]
861            "#,
862        )?;
863
864        let config = CodebookConfigFile::load_with_global_config(
865            Some(workspace_dir.as_path()),
866            Some(override_path.clone()),
867        )?;
868
869        assert_eq!(config.global_config_path(), Some(override_path));
870        assert!(config.is_allowed_word("customword"));
871        Ok(())
872    }
873
874    #[test]
875    fn test_should_ignore_path() {
876        let config = CodebookConfigFile::default();
877        {
878            let mut inner = config.inner.write().unwrap();
879            let mut settings = inner
880                .project_config
881                .content()
882                .cloned()
883                .unwrap_or_else(ConfigSettings::default);
884            settings.ignore_paths.push("target/**/*".to_string());
885            inner.project_config = inner.project_config.clone().with_content_value(settings);
886
887            // Recalculate effective settings
888            let effective = CodebookConfigFile::calculate_effective_settings(
889                &inner.project_config,
890                &inner.global_config,
891            );
892            inner.snapshot = Arc::new(effective);
893        }
894
895        assert!(config.should_ignore_path("target/debug/build".as_ref()));
896        assert!(!config.should_ignore_path("src/main.rs".as_ref()));
897    }
898
899    #[test]
900    fn test_reload() -> Result<(), io::Error> {
901        let temp_dir = TempDir::new().unwrap();
902        let config_path = temp_dir.path().join("codebook.toml");
903
904        // Create initial config
905        // Create a config with the project path set
906        let config = CodebookConfigFile::default();
907        {
908            let mut inner = config.inner.write().unwrap();
909            inner.project_config = WatchedFile::new(Some(config_path.clone()));
910        }
911        config.save()?;
912
913        // Add a word to the toml file
914        let mut file = File::create(&config_path)?;
915        write!(
916            file,
917            r#"
918            words = ["testword"]
919            "#
920        )?;
921
922        // Reload config and verify
923        config.reload()?;
924        assert!(config.is_allowed_word("testword"));
925
926        Ok(())
927    }
928
929    #[test]
930    fn test_reload_when_deleted() -> Result<(), io::Error> {
931        let temp_dir = TempDir::new().unwrap();
932        let config_path = temp_dir.path().join("codebook.toml");
933
934        // Create initial config
935        // Create a config with the project path set
936        let config = CodebookConfigFile::default();
937        {
938            let mut inner = config.inner.write().unwrap();
939            inner.project_config = WatchedFile::new(Some(config_path.clone()));
940        }
941        config.save()?;
942
943        // Add a word to the toml file
944        let mut file = File::create(&config_path)?;
945        write!(
946            file,
947            r#"
948            words = ["testword"]
949            "#
950        )?;
951
952        // Reload config and verify
953        config.reload()?;
954        assert!(config.is_allowed_word("testword"));
955
956        // Delete the config file
957        fs::remove_file(&config_path)?;
958
959        // Reload config and verify
960        config.reload()?;
961        assert!(!config.is_allowed_word("testword"));
962
963        Ok(())
964    }
965
966    #[test]
967    fn test_add_word_case() -> Result<(), io::Error> {
968        let temp_dir = TempDir::new().unwrap();
969        let config_path = temp_dir.path().join("codebook.toml");
970
971        // Create initial config
972        // Create a config with the project path set
973        let config = CodebookConfigFile::default();
974        {
975            let mut inner = config.inner.write().unwrap();
976            inner.project_config = WatchedFile::new(Some(config_path.clone()));
977        }
978        config.save()?;
979
980        // Add a word with mixed case
981        config.add_word("TestWord")?;
982        config.save()?;
983
984        // Reload config and verify with different cases
985        let loaded_config = load_from_file(ConfigType::Global, &config_path)?;
986        assert!(loaded_config.is_allowed_word("testword"));
987        assert!(loaded_config.is_allowed_word("TESTWORD"));
988        assert!(loaded_config.is_allowed_word("TestWord"));
989
990        Ok(())
991    }
992
993    #[test]
994    fn test_add_word_global_case() -> Result<(), io::Error> {
995        let temp_dir = TempDir::new().unwrap();
996        let config_path = temp_dir.path().join("codebook.toml");
997
998        // Create initial config
999        // Create a config with the global path set
1000        let config = CodebookConfigFile::default();
1001        {
1002            let mut inner = config.inner.write().unwrap();
1003            let global_config = WatchedFile::new(Some(config_path.clone()));
1004            inner.global_config = global_config.with_content_value(ConfigSettings::default());
1005        }
1006        config.save_global()?;
1007
1008        // Add a word with mixed case
1009        config.add_word_global("TestWord")?;
1010        config.save_global()?;
1011
1012        // Reload config and verify with different cases
1013        let loaded_config = load_from_file(ConfigType::Global, &config_path)?;
1014        assert!(loaded_config.is_allowed_word("testword"));
1015        assert!(loaded_config.is_allowed_word("TESTWORD"));
1016        assert!(loaded_config.is_allowed_word("TestWord"));
1017
1018        Ok(())
1019    }
1020
1021    #[test]
1022    fn test_global_and_project_config() -> Result<(), io::Error> {
1023        // Create temporary directories for global and project configs
1024        let global_temp = TempDir::new().unwrap();
1025        let project_temp = TempDir::new().unwrap();
1026
1027        // Set up global config path
1028        let global_config_dir = global_temp.path().join("codebook");
1029        fs::create_dir_all(&global_config_dir)?;
1030        let global_config_path = global_config_dir.join("codebook.toml");
1031
1032        // Create global config with some settings
1033        let mut global_file = File::create(&global_config_path)?;
1034        write!(
1035            global_file,
1036            r#"
1037            dictionaries = ["en_US", "fr_FR"]
1038            words = ["globalword1", "globalword2"]
1039            flag_words = ["globaltodo"]
1040            "#
1041        )?;
1042
1043        // Create project config with some different settings
1044        let project_config_path = project_temp.path().join("codebook.toml");
1045        let mut project_file = File::create(&project_config_path)?;
1046        write!(
1047            project_file,
1048            r#"
1049            words = ["projectword"]
1050            flag_words = ["projecttodo"]
1051            use_global = true
1052            "#
1053        )?;
1054
1055        // Create a mock config with our test paths
1056        // Create a config with both paths
1057        let config = CodebookConfigFile::default();
1058        {
1059            let mut inner = config.inner.write().unwrap();
1060            inner.global_config = WatchedFile::new(Some(global_config_path.clone()));
1061            inner.project_config = WatchedFile::new(Some(project_config_path.clone()));
1062        }
1063
1064        // Manually load both configs to test merging
1065        {
1066            let mut inner = config.inner.write().unwrap();
1067            if let Ok(global_settings) =
1068                CodebookConfigFile::load_settings_from_file(&global_config_path)
1069            {
1070                inner.global_config = inner
1071                    .global_config
1072                    .clone()
1073                    .with_content_value(global_settings);
1074            }
1075            if let Ok(project_settings) =
1076                CodebookConfigFile::load_settings_from_file(&project_config_path)
1077            {
1078                inner.project_config = inner
1079                    .project_config
1080                    .clone()
1081                    .with_content_value(project_settings);
1082            }
1083
1084            // Recalculate effective settings after loading both configs
1085            let effective = CodebookConfigFile::calculate_effective_settings(
1086                &inner.project_config,
1087                &inner.global_config,
1088            );
1089            inner.snapshot = Arc::new(effective);
1090        }
1091
1092        // Verify merged results
1093        assert!(config.is_allowed_word("globalword1")); // From global
1094        assert!(config.is_allowed_word("projectword")); // From project
1095        assert!(config.should_flag_word("globaltodo")); // From global
1096        assert!(config.should_flag_word("projecttodo")); // From project
1097
1098        // Verify dictionaries came from global
1099        let dictionaries = config.get_dictionary_ids();
1100        assert_eq!(dictionaries.len(), 2);
1101        assert!(dictionaries.contains(&"en_us".to_string()));
1102        assert!(dictionaries.contains(&"fr_fr".to_string()));
1103
1104        // Now test with use_global = false
1105        let mut project_file = File::create(config.project_config_path().unwrap())?;
1106        write!(
1107            project_file,
1108            r#"
1109            words = ["projectword"]
1110            flag_words = ["projecttodo"]
1111            use_global = false
1112            "#
1113        )?;
1114
1115        // Reload
1116        config.reload()?;
1117
1118        // Now should only see project words
1119        assert!(config.is_allowed_word("projectword")); // From project
1120        assert!(!config.is_allowed_word("globalword1")); // Not used from global
1121        assert!(config.should_flag_word("projecttodo")); // From project
1122        assert!(!config.should_flag_word("globaltodo")); // Not used from global
1123
1124        Ok(())
1125    }
1126}