envx_core/
env_watcher.rs

1use crate::EnvVarManager;
2use color_eyre::Result;
3use notify::{RecommendedWatcher, RecursiveMode};
4use notify_debouncer_mini::{DebounceEventResult, DebouncedEvent, Debouncer, new_debouncer};
5use serde::Deserialize;
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::mpsc::{Receiver, Sender, channel};
9use std::sync::{Arc, Mutex};
10use std::time::Duration;
11use std::{fs, thread};
12
13#[derive(Debug, Clone)]
14pub enum SyncMode {
15    /// Only watch, don't apply changes
16    WatchOnly,
17    /// Apply changes from files to system
18    FileToSystem,
19    /// Apply changes from system to files
20    SystemToFile,
21    /// Bi-directional sync with conflict resolution
22    Bidirectional,
23}
24
25#[derive(Debug, Clone)]
26pub struct WatchConfig {
27    /// Files or directories to watch
28    pub paths: Vec<PathBuf>,
29    /// Sync mode
30    pub mode: SyncMode,
31    /// Auto-reload on changes
32    pub auto_reload: bool,
33    /// Debounce duration (to avoid multiple rapid reloads)
34    pub debounce_duration: Duration,
35    /// File patterns to watch (e.g., "*.env", "*.yaml")
36    pub patterns: Vec<String>,
37    /// Log changes
38    pub log_changes: bool,
39    /// Conflict resolution strategy
40    pub conflict_strategy: ConflictStrategy,
41}
42
43#[derive(Debug, Clone)]
44pub enum ConflictStrategy {
45    /// Use the most recent change
46    UseLatest,
47    /// Prefer file changes
48    PreferFile,
49    /// Prefer system changes
50    PreferSystem,
51    /// Ask user (only in interactive mode)
52    AskUser,
53}
54
55impl Default for WatchConfig {
56    fn default() -> Self {
57        Self {
58            paths: vec![PathBuf::from(".")],
59            mode: SyncMode::FileToSystem,
60            auto_reload: true,
61            debounce_duration: Duration::from_millis(300),
62            patterns: vec![
63                "*.env".to_string(),
64                ".env.*".to_string(),
65                "*.yaml".to_string(),
66                "*.yml".to_string(),
67                "*.toml".to_string(),
68            ],
69            log_changes: true,
70            conflict_strategy: ConflictStrategy::UseLatest,
71        }
72    }
73}
74
75pub struct EnvWatcher {
76    config: WatchConfig,
77    debouncer: Option<Debouncer<RecommendedWatcher>>,
78    stop_signal: Option<Sender<()>>,
79    manager: Arc<Mutex<EnvVarManager>>,
80    change_log: Arc<Mutex<Vec<ChangeEvent>>>,
81    variable_filter: Option<Vec<String>>,
82    output_file: Option<PathBuf>,
83}
84
85#[derive(Debug, Clone, serde::Serialize, Deserialize)]
86pub struct ChangeEvent {
87    pub timestamp: chrono::DateTime<chrono::Utc>,
88    pub path: PathBuf,
89    pub change_type: ChangeType,
90    pub details: String,
91}
92
93#[derive(Debug, Clone, serde::Serialize, Deserialize)]
94pub enum ChangeType {
95    FileCreated,
96    FileModified,
97    FileDeleted,
98    VariableAdded(String),
99    VariableModified(String),
100    VariableDeleted(String),
101}
102
103impl EnvWatcher {
104    #[must_use]
105    pub fn new(config: WatchConfig, manager: EnvVarManager) -> Self {
106        Self {
107            config,
108            debouncer: None,
109            stop_signal: None,
110            manager: Arc::new(Mutex::new(manager)),
111            change_log: Arc::new(Mutex::new(Vec::new())),
112            variable_filter: None,
113            output_file: None,
114        }
115    }
116
117    /// Starts the environment variable watcher.
118    ///
119    /// # Errors
120    ///
121    /// This function will return an error if:
122    /// - The debouncer cannot be created
123    /// - File system watching cannot be initialized for the specified paths
124    /// - The system monitor cannot be started (in `SystemToFile` or Bidirectional modes)
125    pub fn start(&mut self) -> Result<()> {
126        let (tx, rx) = channel();
127        let (stop_tx, stop_rx) = channel();
128
129        // Clone tx for the closure
130        let tx_clone = tx;
131        let log_changes = self.config.log_changes;
132
133        // Create debouncer with proper event handling
134        let mut debouncer = new_debouncer(
135            self.config.debounce_duration,
136            move |result: DebounceEventResult| match result {
137                Ok(events) => {
138                    for event in events {
139                        if log_changes {
140                            println!("🔍 File system event detected: {}", event.path.display());
141                        }
142                        if let Err(e) = tx_clone.send(event) {
143                            eprintln!("Failed to send event: {e:?}");
144                        }
145                    }
146                }
147                Err(errors) => {
148                    eprintln!("Watch error: {errors:?}");
149                }
150            },
151        )?;
152
153        // Get a mutable reference to the watcher before moving debouncer
154        let watcher = debouncer.watcher();
155
156        // Watch specified paths
157        for path in &self.config.paths {
158            if path.exists() {
159                if path.is_file() {
160                    // Watch the parent directory for file changes
161                    if let Some(parent) = path.parent() {
162                        watcher.watch(parent, RecursiveMode::NonRecursive)?;
163                        if self.config.log_changes {
164                            println!("👀 Watching file: {} (via parent directory)", path.display());
165                        }
166                    }
167                } else {
168                    watcher.watch(path, RecursiveMode::Recursive)?;
169                    if self.config.log_changes {
170                        println!("👀 Watching directory: {}", path.display());
171                    }
172                }
173            } else {
174                eprintln!("âš ī¸  Path does not exist: {}", path.display());
175            }
176        }
177
178        // Store the debouncer - this is crucial!
179        self.debouncer = Some(debouncer);
180        self.stop_signal = Some(stop_tx);
181
182        // Spawn handler thread
183        let config = self.config.clone();
184        let manager = Arc::clone(&self.manager);
185        let change_log = Arc::clone(&self.change_log);
186        let variable_filter = self.variable_filter.clone();
187        let output_file = self.output_file.clone();
188
189        thread::spawn(move || {
190            Self::handle_events(
191                &rx,
192                &stop_rx,
193                &config,
194                &manager,
195                &change_log,
196                variable_filter.as_ref(),
197                output_file.as_ref(),
198            );
199        });
200
201        if matches!(self.config.mode, SyncMode::SystemToFile | SyncMode::Bidirectional) {
202            self.start_system_monitor();
203        }
204
205        Ok(())
206    }
207
208    /// Stops the environment variable watcher.
209    ///
210    /// # Errors
211    ///
212    /// This function currently does not return any errors, but returns `Result<()>`
213    /// for future extensibility and consistency with other operations.
214    pub fn stop(&mut self) -> Result<()> {
215        // Send stop signal
216        if let Some(stop_signal) = self.stop_signal.take() {
217            let _ = stop_signal.send(());
218        }
219
220        // Drop the debouncer to stop watching
221        self.debouncer = None;
222
223        if self.config.log_changes {
224            println!("🛑 Stopped watching");
225        }
226
227        Ok(())
228    }
229
230    fn handle_events(
231        rx: &Receiver<DebouncedEvent>,
232        stop_rx: &Receiver<()>,
233        config: &WatchConfig,
234        manager: &Arc<Mutex<EnvVarManager>>,
235        change_log: &Arc<Mutex<Vec<ChangeEvent>>>,
236        variable_filter: Option<&Vec<String>>,
237        output_file: Option<&PathBuf>,
238    ) {
239        loop {
240            // Check for stop signal
241            if stop_rx.try_recv().is_ok() {
242                break;
243            }
244
245            // Process events with timeout to allow checking stop signal
246            match rx.recv_timeout(Duration::from_millis(100)) {
247                Ok(event) => {
248                    if config.log_changes {
249                        println!("📋 Processing event for: {}", event.path.display());
250                    }
251
252                    let path = event.path.clone();
253
254                    // Skip if path matches output file (to avoid infinite loops in bidirectional sync)
255                    if let Some(output) = output_file {
256                        if path == *output && matches!(config.mode, SyncMode::Bidirectional) {
257                            if config.log_changes {
258                                println!("â­ī¸  Skipping output file to avoid loop");
259                            }
260                            continue;
261                        }
262                    }
263
264                    // Check if file matches patterns
265                    if !Self::matches_patterns(&path, &config.patterns) {
266                        if config.log_changes {
267                            println!("â­ī¸  File doesn't match patterns: {}", path.display());
268                        }
269                        continue;
270                    }
271
272                    // Determine the type of change
273                    let change_type = if path.exists() {
274                        if config.log_changes {
275                            println!("âœī¸  Modified: {}", path.display());
276                        }
277                        ChangeType::FileModified
278                    } else {
279                        if config.log_changes {
280                            println!("đŸ—‘ī¸  Deleted: {}", path.display());
281                        }
282                        ChangeType::FileDeleted
283                    };
284
285                    // Handle the change based on sync mode
286                    match config.mode {
287                        SyncMode::WatchOnly => {
288                            Self::log_change(
289                                change_log,
290                                path,
291                                change_type,
292                                "File changed (watch only mode)".to_string(),
293                            );
294                        }
295                        SyncMode::FileToSystem | SyncMode::Bidirectional => {
296                            if matches!(change_type, ChangeType::FileModified | ChangeType::FileCreated) {
297                                if let Err(e) = Self::handle_file_change(
298                                    &path,
299                                    change_type,
300                                    config,
301                                    manager,
302                                    change_log,
303                                    variable_filter,
304                                ) {
305                                    eprintln!("Error handling file change: {e}");
306                                }
307                            }
308                        }
309                        SyncMode::SystemToFile => {
310                            // In this mode, we don't react to file changes
311                            if config.log_changes {
312                                println!("â„šī¸  Ignoring file change in system-to-file mode");
313                            }
314                        }
315                    }
316                }
317                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
318                    // Timeout is normal, continue checking
319                }
320                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
321                    // Channel disconnected, stop
322                    break;
323                }
324            }
325        }
326    }
327
328    fn handle_file_change(
329        path: &Path,
330        _change_type: ChangeType,
331        config: &WatchConfig,
332        manager: &Arc<Mutex<EnvVarManager>>,
333        change_log: &Arc<Mutex<Vec<ChangeEvent>>>,
334        variable_filter: Option<&Vec<String>>,
335    ) -> Result<()> {
336        if !config.auto_reload {
337            return Ok(());
338        }
339
340        // Add a small delay to ensure file write is complete
341        thread::sleep(Duration::from_millis(50));
342
343        // Load and apply changes from file
344        let mut manager = manager.lock().unwrap();
345
346        // Get current state for comparison
347        let before_vars: HashMap<String, String> = manager
348            .list()
349            .into_iter()
350            .filter(|v| {
351                variable_filter
352                    .as_ref()
353                    .is_none_or(|filter| filter.iter().any(|f| v.name.contains(f)))
354            })
355            .map(|v| (v.name.clone(), v.value.clone()))
356            .collect();
357
358        // Load the file based on extension
359        let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
360
361        let load_result = match extension {
362            "env" => Self::load_env_file(path, &mut manager, variable_filter),
363            "yaml" | "yml" => Self::load_yaml_file(path, &mut manager, variable_filter),
364            "json" => Self::load_json_file(path, &mut manager, variable_filter),
365            _ => {
366                // Try to load as .env format by default
367                Self::load_env_file(path, &mut manager, variable_filter)
368            }
369        };
370
371        if let Err(e) = load_result {
372            eprintln!("Failed to load file: {e}");
373            return Err(e);
374        }
375
376        // Compare and log changes
377        let after_vars = manager.list();
378        let mut changes_made = false;
379
380        for var in after_vars {
381            // Skip if filtered
382            if let Some(filter) = variable_filter {
383                if !filter.iter().any(|f| var.name.contains(f)) {
384                    continue;
385                }
386            }
387
388            if let Some(old_value) = before_vars.get(&var.name) {
389                if old_value != &var.value {
390                    Self::log_change(
391                        change_log,
392                        path.to_path_buf(),
393                        ChangeType::VariableModified(var.name.clone()),
394                        format!("Changed {} from '{}' to '{}'", var.name, old_value, var.value),
395                    );
396
397                    if config.log_changes {
398                        println!("  🔄 {} changed from '{}' to '{}'", var.name, old_value, var.value);
399                    }
400                    changes_made = true;
401                }
402            } else {
403                Self::log_change(
404                    change_log,
405                    path.to_path_buf(),
406                    ChangeType::VariableAdded(var.name.clone()),
407                    format!("Added {} = '{}'", var.name, var.value),
408                );
409
410                if config.log_changes {
411                    println!("  ➕ {} = '{}'", var.name, var.value);
412                }
413                changes_made = true;
414            }
415        }
416
417        // Check for deletions
418        for (name, _) in before_vars {
419            if manager.get(&name).is_none() {
420                Self::log_change(
421                    change_log,
422                    path.to_path_buf(),
423                    ChangeType::VariableDeleted(name.clone()),
424                    format!("Deleted {name}"),
425                );
426
427                if config.log_changes {
428                    println!("  ❌ {name} deleted");
429                }
430                changes_made = true;
431            }
432        }
433
434        if !changes_made && config.log_changes {
435            println!("  â„šī¸  No changes detected");
436        }
437
438        Ok(())
439    }
440
441    fn load_env_file(path: &Path, manager: &mut EnvVarManager, variable_filter: Option<&Vec<String>>) -> Result<()> {
442        let content = fs::read_to_string(path)?;
443
444        for line in content.lines() {
445            let line = line.trim();
446            if line.is_empty() || line.starts_with('#') {
447                continue;
448            }
449
450            if let Some((key, value)) = line.split_once('=') {
451                let key = key.trim();
452                let value = value.trim().trim_matches('"').trim_matches('\'');
453
454                // Apply filter if specified
455                if let Some(filter) = variable_filter {
456                    if !filter.iter().any(|f| key.contains(f)) {
457                        continue;
458                    }
459                }
460
461                manager.set(key, value, true)?;
462            }
463        }
464
465        Ok(())
466    }
467
468    fn load_yaml_file(path: &Path, manager: &mut EnvVarManager, variable_filter: Option<&Vec<String>>) -> Result<()> {
469        let content = fs::read_to_string(path)?;
470        let yaml: serde_yaml::Value = serde_yaml::from_str(&content)?;
471
472        if let serde_yaml::Value::Mapping(map) = yaml {
473            for (key, value) in map {
474                if let (Some(key_str), Some(value_str)) = (key.as_str(), value.as_str()) {
475                    // Apply filter if specified
476                    if let Some(filter) = variable_filter {
477                        if !filter.iter().any(|f| key_str.contains(f)) {
478                            continue;
479                        }
480                    }
481
482                    manager.set(key_str, value_str, true)?;
483                }
484            }
485        }
486
487        Ok(())
488    }
489
490    fn load_json_file(path: &Path, manager: &mut EnvVarManager, variable_filter: Option<&Vec<String>>) -> Result<()> {
491        let content = fs::read_to_string(path)?;
492        let json: serde_json::Value = serde_json::from_str(&content)?;
493
494        if let serde_json::Value::Object(map) = json {
495            for (key, value) in map {
496                if let serde_json::Value::String(value_str) = value {
497                    // Apply filter if specified
498                    if let Some(filter) = variable_filter {
499                        if !filter.iter().any(|f| key.contains(f)) {
500                            continue;
501                        }
502                    }
503
504                    manager.set(&key, &value_str, true)?;
505                }
506            }
507        }
508
509        Ok(())
510    }
511
512    fn start_system_monitor(&mut self) {
513        let manager = Arc::clone(&self.manager);
514        let config = self.config.clone();
515        let _change_log = Arc::clone(&self.change_log);
516        let variable_filter = self.variable_filter.clone();
517        let output_file = self.output_file.clone();
518
519        thread::spawn(move || {
520            let mut last_snapshot = HashMap::new();
521
522            loop {
523                thread::sleep(Duration::from_secs(1));
524
525                manager.lock().unwrap().load_all().ok();
526
527                let current_snapshot: HashMap<String, String> = manager
528                    .lock()
529                    .unwrap()
530                    .list()
531                    .iter()
532                    .filter(|v| {
533                        variable_filter
534                            .as_ref()
535                            .is_none_or(|filter| filter.iter().any(|f| v.name.contains(f)))
536                    })
537                    .map(|v| (v.name.clone(), v.value.clone()))
538                    .collect();
539
540                // Check for changes and write to file if needed
541                if matches!(config.mode, SyncMode::SystemToFile | SyncMode::Bidirectional) {
542                    if let Some(ref output) = output_file {
543                        let mut changed = false;
544
545                        for (name, value) in &current_snapshot {
546                            if last_snapshot.get(name) != Some(value) {
547                                changed = true;
548                                if config.log_changes {
549                                    println!("🔄 System change detected: {name} changed");
550                                }
551                            }
552                        }
553
554                        // Check for deletions
555                        for name in last_snapshot.keys() {
556                            if !current_snapshot.contains_key(name) {
557                                changed = true;
558                                if config.log_changes {
559                                    println!("❌ System change detected: {name} deleted");
560                                }
561                            }
562                        }
563
564                        if changed {
565                            // Write to output file
566                            let mut content = String::new();
567                            #[allow(clippy::format_push_string)]
568                            for (name, value) in &current_snapshot {
569                                content.push_str(&format!("{name}={value}\n"));
570                            }
571
572                            if let Err(e) = fs::write(output, &content) {
573                                eprintln!("Failed to write to output file: {e}");
574                            } else if config.log_changes {
575                                println!("💾 Updated output file");
576                            }
577                        }
578                    }
579                }
580
581                last_snapshot = current_snapshot;
582            }
583        });
584    }
585
586    fn matches_patterns(path: &Path, patterns: &[String]) -> bool {
587        let file_name = match path.file_name() {
588            Some(name) => name.to_string_lossy(),
589            None => return false,
590        };
591
592        patterns.iter().any(|pattern| {
593            if pattern.contains('*') {
594                let regex_pattern = pattern.replace('.', r"\.").replace('*', ".*");
595                if let Ok(re) = regex::Regex::new(&format!("^{regex_pattern}$")) {
596                    return re.is_match(&file_name);
597                }
598            }
599            &file_name == pattern
600        })
601    }
602
603    fn log_change(change_log: &Arc<Mutex<Vec<ChangeEvent>>>, path: PathBuf, change_type: ChangeType, details: String) {
604        let event = ChangeEvent {
605            timestamp: chrono::Utc::now(),
606            path,
607            change_type,
608            details,
609        };
610
611        let mut log = change_log.lock().expect("Failed to lock change log");
612        log.push(event);
613
614        // Keep only last 1000 events
615        if log.len() > 1000 {
616            log.drain(0..100);
617        }
618    }
619
620    /// Returns a clone of the change log containing all recorded change events.
621    ///
622    /// # Panics
623    ///
624    /// Panics if the change log mutex is poisoned (i.e., another thread panicked while holding the lock).
625    #[must_use]
626    pub fn get_change_log(&self) -> Vec<ChangeEvent> {
627        self.change_log.lock().expect("Failed to lock change log").clone()
628    }
629
630    /// Exports the change log to a JSON file at the specified path.
631    ///
632    /// # Errors
633    ///
634    /// This function will return an error if:
635    /// - The change log cannot be serialized to JSON
636    /// - The file cannot be written to the specified path
637    pub fn export_change_log(&self, path: &Path) -> Result<()> {
638        let log = self.get_change_log();
639        let json = serde_json::to_string_pretty(&log)?;
640        fs::write(path, json)?;
641        Ok(())
642    }
643
644    pub fn set_variable_filter(&mut self, vars: Vec<String>) {
645        self.variable_filter = Some(vars);
646    }
647
648    /// Set output file for system-to-file sync
649    pub fn set_output_file(&mut self, path: PathBuf) {
650        self.output_file = Some(path);
651    }
652}
653
654// Add this at the end of the file
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659    use std::fs;
660    use std::time::Duration;
661    use tempfile::TempDir;
662
663    fn create_test_manager() -> EnvVarManager {
664        let mut manager = EnvVarManager::new();
665        manager.set("TEST_VAR", "initial_value", false).unwrap();
666        manager.set("ANOTHER_VAR", "another_value", false).unwrap();
667        manager
668    }
669
670    fn create_test_config(temp_dir: &Path) -> WatchConfig {
671        WatchConfig {
672            paths: vec![temp_dir.to_path_buf()],
673            mode: SyncMode::FileToSystem,
674            auto_reload: true,
675            debounce_duration: Duration::from_millis(100),
676            patterns: vec!["*.env".to_string(), "*.json".to_string(), "*.yaml".to_string()],
677            log_changes: false,
678            conflict_strategy: ConflictStrategy::UseLatest,
679        }
680    }
681
682    fn wait_for_debounce() {
683        thread::sleep(Duration::from_millis(200));
684    }
685
686    #[test]
687    fn test_env_watcher_creation() {
688        let config = WatchConfig::default();
689        let manager = create_test_manager();
690        let watcher = EnvWatcher::new(config, manager);
691
692        assert!(watcher.debouncer.is_none());
693        assert!(watcher.stop_signal.is_none());
694        assert!(watcher.variable_filter.is_none());
695        assert!(watcher.output_file.is_none());
696    }
697
698    #[test]
699    fn test_watch_config_default() {
700        let config = WatchConfig::default();
701
702        assert_eq!(config.paths, vec![PathBuf::from(".")]);
703        assert!(matches!(config.mode, SyncMode::FileToSystem));
704        assert!(config.auto_reload);
705        assert_eq!(config.debounce_duration, Duration::from_millis(300));
706        assert_eq!(config.patterns.len(), 5);
707        assert!(config.log_changes);
708        assert!(matches!(config.conflict_strategy, ConflictStrategy::UseLatest));
709    }
710
711    #[test]
712    fn test_variable_filter() {
713        let config = WatchConfig::default();
714        let manager = create_test_manager();
715        let mut watcher = EnvWatcher::new(config, manager);
716
717        assert!(watcher.variable_filter.is_none());
718
719        watcher.set_variable_filter(vec!["TEST".to_string(), "API".to_string()]);
720        assert!(watcher.variable_filter.is_some());
721        assert_eq!(watcher.variable_filter.as_ref().unwrap().len(), 2);
722    }
723
724    #[test]
725    fn test_output_file() {
726        let config = WatchConfig::default();
727        let manager = create_test_manager();
728        let mut watcher = EnvWatcher::new(config, manager);
729
730        assert!(watcher.output_file.is_none());
731
732        let output_path = PathBuf::from("output.env");
733        watcher.set_output_file(output_path.clone());
734        assert_eq!(watcher.output_file, Some(output_path));
735    }
736
737    #[test]
738    fn test_change_log() {
739        let config = WatchConfig::default();
740        let manager = create_test_manager();
741        let watcher = EnvWatcher::new(config, manager);
742
743        let log = watcher.get_change_log();
744        assert!(log.is_empty());
745
746        // Add a change event
747        let change_event = ChangeEvent {
748            timestamp: chrono::Utc::now(),
749            path: PathBuf::from("test.env"),
750            change_type: ChangeType::FileModified,
751            details: "Test change".to_string(),
752        };
753
754        watcher.change_log.lock().unwrap().push(change_event);
755
756        let log = watcher.get_change_log();
757        assert_eq!(log.len(), 1);
758        assert_eq!(log[0].details, "Test change");
759    }
760
761    #[test]
762    fn test_export_change_log() {
763        let temp_dir = TempDir::new().unwrap();
764        let log_file = temp_dir.path().join("changes.json");
765
766        let config = WatchConfig::default();
767        let manager = create_test_manager();
768        let watcher = EnvWatcher::new(config, manager);
769
770        // Add some change events
771        let mut log = watcher.change_log.lock().unwrap();
772        log.push(ChangeEvent {
773            timestamp: chrono::Utc::now(),
774            path: PathBuf::from("test1.env"),
775            change_type: ChangeType::FileCreated,
776            details: "Created file".to_string(),
777        });
778        log.push(ChangeEvent {
779            timestamp: chrono::Utc::now(),
780            path: PathBuf::from("test2.env"),
781            change_type: ChangeType::VariableAdded("NEW_VAR".to_string()),
782            details: "Added NEW_VAR".to_string(),
783        });
784        drop(log);
785
786        // Export the log
787        watcher.export_change_log(&log_file).unwrap();
788
789        // Verify the file exists and contains valid JSON
790        assert!(log_file.exists());
791        let content = fs::read_to_string(&log_file).unwrap();
792        let parsed: Vec<ChangeEvent> = serde_json::from_str(&content).unwrap();
793        assert_eq!(parsed.len(), 2);
794    }
795
796    #[test]
797    fn test_matches_patterns() {
798        let patterns = vec!["*.env".to_string(), "*.yaml".to_string(), "config.json".to_string()];
799
800        assert!(EnvWatcher::matches_patterns(&PathBuf::from("test.env"), &patterns));
801        assert!(EnvWatcher::matches_patterns(&PathBuf::from("app.yaml"), &patterns));
802        assert!(EnvWatcher::matches_patterns(&PathBuf::from("config.json"), &patterns));
803        assert!(!EnvWatcher::matches_patterns(&PathBuf::from("test.txt"), &patterns));
804        assert!(!EnvWatcher::matches_patterns(&PathBuf::from("README.md"), &patterns));
805    }
806
807    #[test]
808    fn test_load_env_file() {
809        let temp_dir = TempDir::new().unwrap();
810        let env_file = temp_dir.path().join("test.env");
811
812        // Create a test .env file
813        let content = r#"
814# Comment line
815TEST_VAR=test_value
816ANOTHER_VAR=another_value
817QUOTED_VAR="quoted value"
818SINGLE_QUOTED='single quoted'
819        "#;
820        fs::write(&env_file, content).unwrap();
821
822        let mut manager = EnvVarManager::new();
823        EnvWatcher::load_env_file(&env_file, &mut manager, None).unwrap();
824
825        assert_eq!(manager.get("TEST_VAR").unwrap().value, "test_value");
826        assert_eq!(manager.get("ANOTHER_VAR").unwrap().value, "another_value");
827        assert_eq!(manager.get("QUOTED_VAR").unwrap().value, "quoted value");
828        assert_eq!(manager.get("SINGLE_QUOTED").unwrap().value, "single quoted");
829    }
830
831    #[test]
832    fn test_load_env_file_with_filter() {
833        let temp_dir = TempDir::new().unwrap();
834        let env_file = temp_dir.path().join("test.env");
835
836        let content = r"
837TEST_VAR=test_value
838API_KEY=secret_key
839DATABASE_URL=postgres://localhost
840API_SECRET=another_secret
841        ";
842        fs::write(&env_file, content).unwrap();
843
844        let mut manager = EnvVarManager::new();
845        let filter = vec!["API".to_string()];
846        EnvWatcher::load_env_file(&env_file, &mut manager, Some(&filter)).unwrap();
847
848        assert!(manager.get("API_KEY").is_some());
849        assert!(manager.get("API_SECRET").is_some());
850        assert!(manager.get("TEST_VAR").is_none());
851        assert!(manager.get("DATABASE_URL").is_none());
852    }
853
854    #[test]
855    fn test_load_json_file() {
856        let temp_dir = TempDir::new().unwrap();
857        let json_file = temp_dir.path().join("config.json");
858
859        let content = r#"{
860            "TEST_VAR": "json_value",
861            "NUMBER_VAR": "42",
862            "BOOL_VAR": "true"
863        }"#;
864        fs::write(&json_file, content).unwrap();
865
866        let mut manager = EnvVarManager::new();
867        EnvWatcher::load_json_file(&json_file, &mut manager, None).unwrap();
868
869        assert_eq!(manager.get("TEST_VAR").unwrap().value, "json_value");
870        assert_eq!(manager.get("NUMBER_VAR").unwrap().value, "42");
871        assert_eq!(manager.get("BOOL_VAR").unwrap().value, "true");
872    }
873
874    #[test]
875    fn test_load_yaml_file() {
876        let temp_dir = TempDir::new().unwrap();
877        let yaml_file = temp_dir.path().join("config.yaml");
878
879        let content = r#"
880TEST_VAR: yaml_value
881NESTED_VAR: nested_value
882QUOTED: "quoted yaml"
883        "#;
884        fs::write(&yaml_file, content).unwrap();
885
886        let mut manager = EnvVarManager::new();
887        EnvWatcher::load_yaml_file(&yaml_file, &mut manager, None).unwrap();
888
889        assert_eq!(manager.get("TEST_VAR").unwrap().value, "yaml_value");
890        assert_eq!(manager.get("NESTED_VAR").unwrap().value, "nested_value");
891        assert_eq!(manager.get("QUOTED").unwrap().value, "quoted yaml");
892    }
893
894    #[test]
895    fn test_start_and_stop() {
896        let temp_dir = TempDir::new().unwrap();
897        let config = create_test_config(temp_dir.path());
898        let manager = create_test_manager();
899        let mut watcher = EnvWatcher::new(config, manager);
900
901        // Start the watcher
902        watcher.start().unwrap();
903        assert!(watcher.debouncer.is_some());
904        assert!(watcher.stop_signal.is_some());
905
906        // Stop the watcher
907        watcher.stop().unwrap();
908        assert!(watcher.debouncer.is_none());
909        assert!(watcher.stop_signal.is_none());
910    }
911
912    #[test]
913    fn test_file_watching_integration() {
914        let temp_dir = TempDir::new().unwrap();
915        let env_file = temp_dir.path().join("test.env");
916
917        // Create initial file
918        fs::write(&env_file, "INITIAL=value1").unwrap();
919
920        let config = WatchConfig {
921            paths: vec![env_file.clone()],
922            mode: SyncMode::FileToSystem,
923            auto_reload: true,
924            debounce_duration: Duration::from_millis(50),
925            patterns: vec!["*.env".to_string()],
926            log_changes: false,
927            conflict_strategy: ConflictStrategy::UseLatest,
928        };
929
930        let manager = EnvVarManager::new();
931        let mut watcher = EnvWatcher::new(config, manager);
932
933        // Start watching
934        watcher.start().unwrap();
935
936        // Wait for initial setup
937        wait_for_debounce();
938
939        // Modify the file
940        fs::write(&env_file, "INITIAL=value2\nNEW_VAR=new_value").unwrap();
941
942        // Wait for changes to be detected and processed
943        thread::sleep(Duration::from_millis(300));
944
945        // Check that changes were detected
946        let log = watcher.get_change_log();
947        assert!(!log.is_empty());
948
949        // Clean up
950        watcher.stop().unwrap();
951    }
952
953    #[test]
954    fn test_sync_mode_watch_only() {
955        let temp_dir = TempDir::new().unwrap();
956        let config = WatchConfig {
957            paths: vec![temp_dir.path().to_path_buf()],
958            mode: SyncMode::WatchOnly,
959            auto_reload: true,
960            debounce_duration: Duration::from_millis(50),
961            patterns: vec!["*.env".to_string()],
962            log_changes: false,
963            conflict_strategy: ConflictStrategy::UseLatest,
964        };
965
966        let manager = create_test_manager();
967        let watcher = EnvWatcher::new(config, manager);
968
969        // In watch-only mode, changes should be logged but not applied
970        let log = watcher.get_change_log();
971        assert!(log.is_empty());
972    }
973
974    #[test]
975    fn test_system_to_file_mode() {
976        let temp_dir = TempDir::new().unwrap();
977        let output_file = temp_dir.path().join("output.env");
978
979        let config = WatchConfig {
980            paths: vec![temp_dir.path().to_path_buf()],
981            mode: SyncMode::SystemToFile,
982            auto_reload: true,
983            debounce_duration: Duration::from_millis(50),
984            patterns: vec!["*.env".to_string()],
985            log_changes: false,
986            conflict_strategy: ConflictStrategy::UseLatest,
987        };
988
989        let manager = create_test_manager();
990        let mut watcher = EnvWatcher::new(config, manager);
991        watcher.set_output_file(output_file.clone());
992
993        // Start watching
994        watcher.start().unwrap();
995
996        // Wait for system monitor to run
997        thread::sleep(Duration::from_millis(1500));
998
999        // Check if output file was created
1000        assert!(output_file.exists());
1001
1002        // Clean up
1003        watcher.stop().unwrap();
1004    }
1005
1006    #[test]
1007    fn test_change_log_limit() {
1008        let config = WatchConfig::default();
1009        let manager = create_test_manager();
1010        let watcher = EnvWatcher::new(config, manager);
1011
1012        // Add more than 1000 events using the log_change function
1013        for i in 0..1100 {
1014            EnvWatcher::log_change(
1015                &watcher.change_log,
1016                PathBuf::from(format!("test{i}.env")),
1017                ChangeType::FileModified,
1018                format!("Change {i}"),
1019            );
1020        }
1021
1022        // Check that old events were removed
1023        let current_log = watcher.get_change_log();
1024        assert_eq!(current_log.len(), 1000);
1025        assert_eq!(current_log[0].details, "Change 100");
1026    }
1027
1028    #[test]
1029    fn test_handle_file_change_no_auto_reload() {
1030        let temp_dir = TempDir::new().unwrap();
1031        let env_file = temp_dir.path().join("test.env");
1032        fs::write(&env_file, "TEST=value").unwrap();
1033
1034        let config = WatchConfig {
1035            paths: vec![env_file.clone()],
1036            mode: SyncMode::FileToSystem,
1037            auto_reload: false, // Disabled
1038            debounce_duration: Duration::from_millis(50),
1039            patterns: vec!["*.env".to_string()],
1040            log_changes: false,
1041            conflict_strategy: ConflictStrategy::UseLatest,
1042        };
1043
1044        let manager = EnvVarManager::new();
1045        let manager_arc = Arc::new(Mutex::new(manager));
1046        let change_log = Arc::new(Mutex::new(Vec::new()));
1047
1048        // Should return Ok without loading the file
1049        let result = EnvWatcher::handle_file_change(
1050            &env_file,
1051            ChangeType::FileModified,
1052            &config,
1053            &manager_arc,
1054            &change_log,
1055            None,
1056        );
1057
1058        assert!(result.is_ok());
1059        assert!(manager_arc.lock().unwrap().get("TEST").is_none());
1060    }
1061
1062    #[test]
1063    fn test_bidirectional_sync() {
1064        let temp_dir = TempDir::new().unwrap();
1065        let sync_file = temp_dir.path().join("sync.env");
1066
1067        let config = WatchConfig {
1068            paths: vec![sync_file.clone()],
1069            mode: SyncMode::Bidirectional,
1070            auto_reload: true,
1071            debounce_duration: Duration::from_millis(50),
1072            patterns: vec!["*.env".to_string()],
1073            log_changes: false,
1074            conflict_strategy: ConflictStrategy::UseLatest,
1075        };
1076
1077        let manager = create_test_manager();
1078        let mut watcher = EnvWatcher::new(config, manager);
1079        watcher.set_output_file(sync_file.clone());
1080
1081        // Start watching
1082        watcher.start().unwrap();
1083
1084        // Create/modify the sync file
1085        fs::write(&sync_file, "BIDIRECTIONAL=test").unwrap();
1086
1087        // Wait for processing
1088        wait_for_debounce();
1089        thread::sleep(Duration::from_millis(200));
1090
1091        // Clean up
1092        watcher.stop().unwrap();
1093    }
1094
1095    #[test]
1096    fn test_conflict_strategy() {
1097        let strategies = vec![
1098            ConflictStrategy::UseLatest,
1099            ConflictStrategy::PreferFile,
1100            ConflictStrategy::PreferSystem,
1101            ConflictStrategy::AskUser,
1102        ];
1103
1104        #[allow(clippy::field_reassign_with_default)]
1105        for strategy in strategies {
1106            let mut config = WatchConfig::default();
1107            config.conflict_strategy = strategy.clone();
1108
1109            #[allow(clippy::assertions_on_constants)]
1110            match strategy {
1111                ConflictStrategy::UseLatest
1112                | ConflictStrategy::PreferFile
1113                | ConflictStrategy::PreferSystem
1114                | ConflictStrategy::AskUser => assert!(true),
1115            }
1116        }
1117    }
1118}