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
22pub 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#[derive(Debug)]
38struct ConfigInner {
39 project_config: WatchedFile<ConfigSettings>,
41 global_config: WatchedFile<ConfigSettings>,
43 snapshot: Arc<ConfigSettings>,
45 regex_cache: Option<Vec<Regex>>,
47}
48
49#[derive(Debug)]
50pub struct CodebookConfigFile {
51 inner: RwLock<ConfigInner>,
53 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 pub fn load(current_dir: Option<&Path>) -> Result<Self, io::Error> {
76 Self::load_with_global_config(current_dir, None)
77 }
78
79 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(¤t_dir, global_config_path)
92 }
93 }
94
95 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 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 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 let default_path = start_dir.join(USER_CONFIG_FILES[0]);
149 inner.project_config = WatchedFile::new(Some(default_path));
150 }
151
152 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 fn find_global_config_path() -> Option<PathBuf> {
162 if cfg!(unix) {
164 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 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 fn find_project_config(start_dir: &Path) -> Result<Option<PathBuf>, io::Error> {
192 let config_files = USER_CONFIG_FILES;
193
194 let mut current_dir = Some(start_dir.to_path_buf());
196
197 while let Some(dir) = current_dir {
198 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 current_dir = dir.parent().map(PathBuf::from);
208 }
209
210 Ok(None)
211 }
212
213 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 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 fn snapshot(&self) -> Arc<ConfigSettings> {
255 self.inner.read().unwrap().snapshot.clone()
256 }
257
258 pub fn reload(&self) -> Result<bool, io::Error> {
260 let mut inner = self.inner.write().unwrap();
261 let mut changed = false;
262
263 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 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 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; }
306
307 Ok(changed)
308 }
309
310 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 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 if let Some(parent) = global_config_path.parent() {
367 fs::create_dir_all(parent)?;
368 }
369 fs::write(&global_config_path, content)
370 }
371 pub fn clean_cache(&self) {
373 let dir_path = self.cache_dir.clone();
374 if !dir_path.is_dir() {
376 return;
377 }
378
379 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 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 let _ = fs::remove_dir_all(path);
396 } else {
397 let _ = fs::remove_file(path);
399 }
400 }
401 }
402 }
403
404 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 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 fn add_word(&self, word: &str) -> Result<bool, io::Error> {
475 Ok(self.update_project_settings(|settings| helpers::insert_word(settings, word)))
476 }
477 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 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 fn get_dictionary_ids(&self) -> Vec<String> {
489 let snapshot = self.snapshot();
490 helpers::dictionary_ids(&snapshot)
491 }
492
493 fn should_ignore_path(&self, path: &Path) -> bool {
495 let snapshot = self.snapshot();
496 helpers::should_ignore_path(&snapshot, path)
497 }
498
499 fn is_allowed_word(&self, word: &str) -> bool {
501 let snapshot = self.snapshot();
502 helpers::is_allowed_word(&snapshot, word)
503 }
504
505 fn should_flag_word(&self, word: &str) -> bool {
507 let snapshot = self.snapshot();
508 helpers::should_flag_word(&snapshot, word)
509 }
510
511 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 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 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 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 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 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 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 assert!(!global_dir.exists());
686
687 config.save_global()?;
689
690 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 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 config.add_word("testword")?;
713 config.save()?;
714
715 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 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 config.add_word_global("testword")?;
739 config.save_global()?;
740
741 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 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 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 config.reload()?;
803 assert!(config.get_ignore_patterns().unwrap().len() == 2);
804
805 let mut file = File::create(&config_path)?;
807 write!(
808 file,
809 r#"
810 ignore_patterns = []
811 "#
812 )?;
813
814 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 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 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 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 let mut file = File::create(&config_path)?;
915 write!(
916 file,
917 r#"
918 words = ["testword"]
919 "#
920 )?;
921
922 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 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 let mut file = File::create(&config_path)?;
945 write!(
946 file,
947 r#"
948 words = ["testword"]
949 "#
950 )?;
951
952 config.reload()?;
954 assert!(config.is_allowed_word("testword"));
955
956 fs::remove_file(&config_path)?;
958
959 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 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 config.add_word("TestWord")?;
982 config.save()?;
983
984 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 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 config.add_word_global("TestWord")?;
1010 config.save_global()?;
1011
1012 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 let global_temp = TempDir::new().unwrap();
1025 let project_temp = TempDir::new().unwrap();
1026
1027 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 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 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 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 {
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 let effective = CodebookConfigFile::calculate_effective_settings(
1086 &inner.project_config,
1087 &inner.global_config,
1088 );
1089 inner.snapshot = Arc::new(effective);
1090 }
1091
1092 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();
1100 assert_eq!(dictionaries.len(), 2);
1101 assert!(dictionaries.contains(&"en_us".to_string()));
1102 assert!(dictionaries.contains(&"fr_fr".to_string()));
1103
1104 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 config.reload()?;
1117
1118 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(())
1125 }
1126}