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 std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use std::sync::mpsc::{Receiver, Sender, channel};
8use std::sync::{Arc, Mutex};
9use std::time::Duration;
10use std::{fs, thread};
11
12#[derive(Debug, Clone)]
13pub enum SyncMode {
14    /// Only watch, don't apply changes
15    WatchOnly,
16    /// Apply changes from files to system
17    FileToSystem,
18    /// Apply changes from system to files
19    SystemToFile,
20    /// Bi-directional sync with conflict resolution
21    Bidirectional,
22}
23
24#[derive(Debug, Clone)]
25pub struct WatchConfig {
26    /// Files or directories to watch
27    pub paths: Vec<PathBuf>,
28    /// Sync mode
29    pub mode: SyncMode,
30    /// Auto-reload on changes
31    pub auto_reload: bool,
32    /// Debounce duration (to avoid multiple rapid reloads)
33    pub debounce_duration: Duration,
34    /// File patterns to watch (e.g., "*.env", "*.yaml")
35    pub patterns: Vec<String>,
36    /// Log changes
37    pub log_changes: bool,
38    /// Conflict resolution strategy
39    pub conflict_strategy: ConflictStrategy,
40}
41
42#[derive(Debug, Clone)]
43pub enum ConflictStrategy {
44    /// Use the most recent change
45    UseLatest,
46    /// Prefer file changes
47    PreferFile,
48    /// Prefer system changes
49    PreferSystem,
50    /// Ask user (only in interactive mode)
51    AskUser,
52}
53
54impl Default for WatchConfig {
55    fn default() -> Self {
56        Self {
57            paths: vec![PathBuf::from(".")],
58            mode: SyncMode::FileToSystem,
59            auto_reload: true,
60            debounce_duration: Duration::from_millis(300),
61            patterns: vec![
62                "*.env".to_string(),
63                ".env.*".to_string(),
64                "*.yaml".to_string(),
65                "*.yml".to_string(),
66                "*.toml".to_string(),
67            ],
68            log_changes: true,
69            conflict_strategy: ConflictStrategy::UseLatest,
70        }
71    }
72}
73
74pub struct EnvWatcher {
75    config: WatchConfig,
76    debouncer: Option<Debouncer<RecommendedWatcher>>,
77    stop_signal: Option<Sender<()>>,
78    manager: Arc<Mutex<EnvVarManager>>,
79    change_log: Arc<Mutex<Vec<ChangeEvent>>>,
80    variable_filter: Option<Vec<String>>,
81    output_file: Option<PathBuf>,
82}
83
84#[derive(Debug, Clone, serde::Serialize)]
85pub struct ChangeEvent {
86    pub timestamp: chrono::DateTime<chrono::Utc>,
87    pub path: PathBuf,
88    pub change_type: ChangeType,
89    pub details: String,
90}
91
92#[derive(Debug, Clone, serde::Serialize)]
93pub enum ChangeType {
94    FileCreated,
95    FileModified,
96    FileDeleted,
97    VariableAdded(String),
98    VariableModified(String),
99    VariableDeleted(String),
100}
101
102impl EnvWatcher {
103    #[must_use]
104    pub fn new(config: WatchConfig, manager: EnvVarManager) -> Self {
105        Self {
106            config,
107            debouncer: None,
108            stop_signal: None,
109            manager: Arc::new(Mutex::new(manager)),
110            change_log: Arc::new(Mutex::new(Vec::new())),
111            variable_filter: None,
112            output_file: None,
113        }
114    }
115
116    /// Starts the environment variable watcher.
117    ///
118    /// # Errors
119    ///
120    /// This function will return an error if:
121    /// - The debouncer cannot be created
122    /// - File system watching cannot be initialized for the specified paths
123    /// - The system monitor cannot be started (in `SystemToFile` or Bidirectional modes)
124    pub fn start(&mut self) -> Result<()> {
125        let (tx, rx) = channel();
126        let (stop_tx, stop_rx) = channel();
127
128        // Clone tx for the closure
129        let tx_clone = tx;
130        let log_changes = self.config.log_changes;
131
132        // Create debouncer with proper event handling
133        let mut debouncer = new_debouncer(
134            self.config.debounce_duration,
135            move |result: DebounceEventResult| match result {
136                Ok(events) => {
137                    for event in events {
138                        if log_changes {
139                            println!("🔍 File system event detected: {}", event.path.display());
140                        }
141                        if let Err(e) = tx_clone.send(event) {
142                            eprintln!("Failed to send event: {e:?}");
143                        }
144                    }
145                }
146                Err(errors) => {
147                    eprintln!("Watch error: {errors:?}");
148                }
149            },
150        )?;
151
152        // Get a mutable reference to the watcher before moving debouncer
153        let watcher = debouncer.watcher();
154
155        // Watch specified paths
156        for path in &self.config.paths {
157            if path.exists() {
158                if path.is_file() {
159                    // Watch the parent directory for file changes
160                    if let Some(parent) = path.parent() {
161                        watcher.watch(parent, RecursiveMode::NonRecursive)?;
162                        if self.config.log_changes {
163                            println!("👀 Watching file: {} (via parent directory)", path.display());
164                        }
165                    }
166                } else {
167                    watcher.watch(path, RecursiveMode::Recursive)?;
168                    if self.config.log_changes {
169                        println!("👀 Watching directory: {}", path.display());
170                    }
171                }
172            } else {
173                eprintln!("âš ī¸  Path does not exist: {}", path.display());
174            }
175        }
176
177        // Store the debouncer - this is crucial!
178        self.debouncer = Some(debouncer);
179        self.stop_signal = Some(stop_tx);
180
181        // Spawn handler thread
182        let config = self.config.clone();
183        let manager = Arc::clone(&self.manager);
184        let change_log = Arc::clone(&self.change_log);
185        let variable_filter = self.variable_filter.clone();
186        let output_file = self.output_file.clone();
187
188        thread::spawn(move || {
189            Self::handle_events(
190                &rx,
191                &stop_rx,
192                &config,
193                &manager,
194                &change_log,
195                variable_filter.as_ref(),
196                output_file.as_ref(),
197            );
198        });
199
200        if matches!(self.config.mode, SyncMode::SystemToFile | SyncMode::Bidirectional) {
201            self.start_system_monitor();
202        }
203
204        Ok(())
205    }
206
207    /// Stops the environment variable watcher.
208    ///
209    /// # Errors
210    ///
211    /// This function currently does not return any errors, but returns `Result<()>`
212    /// for future extensibility and consistency with other operations.
213    pub fn stop(&mut self) -> Result<()> {
214        // Send stop signal
215        if let Some(stop_signal) = self.stop_signal.take() {
216            let _ = stop_signal.send(());
217        }
218
219        // Drop the debouncer to stop watching
220        self.debouncer = None;
221
222        if self.config.log_changes {
223            println!("🛑 Stopped watching");
224        }
225
226        Ok(())
227    }
228
229    fn handle_events(
230        rx: &Receiver<DebouncedEvent>,
231        stop_rx: &Receiver<()>,
232        config: &WatchConfig,
233        manager: &Arc<Mutex<EnvVarManager>>,
234        change_log: &Arc<Mutex<Vec<ChangeEvent>>>,
235        variable_filter: Option<&Vec<String>>,
236        output_file: Option<&PathBuf>,
237    ) {
238        loop {
239            // Check for stop signal
240            if stop_rx.try_recv().is_ok() {
241                break;
242            }
243
244            // Process events with timeout to allow checking stop signal
245            match rx.recv_timeout(Duration::from_millis(100)) {
246                Ok(event) => {
247                    if config.log_changes {
248                        println!("📋 Processing event for: {}", event.path.display());
249                    }
250
251                    let path = event.path.clone();
252
253                    // Skip if path matches output file (to avoid infinite loops in bidirectional sync)
254                    if let Some(output) = output_file {
255                        if path == *output && matches!(config.mode, SyncMode::Bidirectional) {
256                            if config.log_changes {
257                                println!("â­ī¸  Skipping output file to avoid loop");
258                            }
259                            continue;
260                        }
261                    }
262
263                    // Check if file matches patterns
264                    if !Self::matches_patterns(&path, &config.patterns) {
265                        if config.log_changes {
266                            println!("â­ī¸  File doesn't match patterns: {}", path.display());
267                        }
268                        continue;
269                    }
270
271                    // Determine the type of change
272                    let change_type = if path.exists() {
273                        if config.log_changes {
274                            println!("âœī¸  Modified: {}", path.display());
275                        }
276                        ChangeType::FileModified
277                    } else {
278                        if config.log_changes {
279                            println!("đŸ—‘ī¸  Deleted: {}", path.display());
280                        }
281                        ChangeType::FileDeleted
282                    };
283
284                    // Handle the change based on sync mode
285                    match config.mode {
286                        SyncMode::WatchOnly => {
287                            Self::log_change(
288                                change_log,
289                                path,
290                                change_type,
291                                "File changed (watch only mode)".to_string(),
292                            );
293                        }
294                        SyncMode::FileToSystem | SyncMode::Bidirectional => {
295                            if matches!(change_type, ChangeType::FileModified | ChangeType::FileCreated) {
296                                if let Err(e) = Self::handle_file_change(
297                                    &path,
298                                    change_type,
299                                    config,
300                                    manager,
301                                    change_log,
302                                    variable_filter,
303                                ) {
304                                    eprintln!("Error handling file change: {e}");
305                                }
306                            }
307                        }
308                        SyncMode::SystemToFile => {
309                            // In this mode, we don't react to file changes
310                            if config.log_changes {
311                                println!("â„šī¸  Ignoring file change in system-to-file mode");
312                            }
313                        }
314                    }
315                }
316                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
317                    // Timeout is normal, continue checking
318                }
319                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
320                    // Channel disconnected, stop
321                    break;
322                }
323            }
324        }
325    }
326
327    fn handle_file_change(
328        path: &Path,
329        _change_type: ChangeType,
330        config: &WatchConfig,
331        manager: &Arc<Mutex<EnvVarManager>>,
332        change_log: &Arc<Mutex<Vec<ChangeEvent>>>,
333        variable_filter: Option<&Vec<String>>,
334    ) -> Result<()> {
335        if !config.auto_reload {
336            return Ok(());
337        }
338
339        // Add a small delay to ensure file write is complete
340        thread::sleep(Duration::from_millis(50));
341
342        // Load and apply changes from file
343        let mut manager = manager.lock().unwrap();
344
345        // Get current state for comparison
346        let before_vars: HashMap<String, String> = manager
347            .list()
348            .into_iter()
349            .filter(|v| {
350                variable_filter
351                    .as_ref()
352                    .is_none_or(|filter| filter.iter().any(|f| v.name.contains(f)))
353            })
354            .map(|v| (v.name.clone(), v.value.clone()))
355            .collect();
356
357        // Load the file based on extension
358        let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
359
360        let load_result = match extension {
361            "env" => Self::load_env_file(path, &mut manager, variable_filter),
362            "yaml" | "yml" => Self::load_yaml_file(path, &mut manager, variable_filter),
363            "json" => Self::load_json_file(path, &mut manager, variable_filter),
364            _ => {
365                // Try to load as .env format by default
366                Self::load_env_file(path, &mut manager, variable_filter)
367            }
368        };
369
370        if let Err(e) = load_result {
371            eprintln!("Failed to load file: {e}");
372            return Err(e);
373        }
374
375        // Compare and log changes
376        let after_vars = manager.list();
377        let mut changes_made = false;
378
379        for var in after_vars {
380            // Skip if filtered
381            if let Some(filter) = variable_filter {
382                if !filter.iter().any(|f| var.name.contains(f)) {
383                    continue;
384                }
385            }
386
387            if let Some(old_value) = before_vars.get(&var.name) {
388                if old_value != &var.value {
389                    Self::log_change(
390                        change_log,
391                        path.to_path_buf(),
392                        ChangeType::VariableModified(var.name.clone()),
393                        format!("Changed {} from '{}' to '{}'", var.name, old_value, var.value),
394                    );
395
396                    if config.log_changes {
397                        println!("  🔄 {} changed from '{}' to '{}'", var.name, old_value, var.value);
398                    }
399                    changes_made = true;
400                }
401            } else {
402                Self::log_change(
403                    change_log,
404                    path.to_path_buf(),
405                    ChangeType::VariableAdded(var.name.clone()),
406                    format!("Added {} = '{}'", var.name, var.value),
407                );
408
409                if config.log_changes {
410                    println!("  ➕ {} = '{}'", var.name, var.value);
411                }
412                changes_made = true;
413            }
414        }
415
416        // Check for deletions
417        for (name, _) in before_vars {
418            if manager.get(&name).is_none() {
419                Self::log_change(
420                    change_log,
421                    path.to_path_buf(),
422                    ChangeType::VariableDeleted(name.clone()),
423                    format!("Deleted {name}"),
424                );
425
426                if config.log_changes {
427                    println!("  ❌ {name} deleted");
428                }
429                changes_made = true;
430            }
431        }
432
433        if !changes_made && config.log_changes {
434            println!("  â„šī¸  No changes detected");
435        }
436
437        Ok(())
438    }
439
440    fn load_env_file(path: &Path, manager: &mut EnvVarManager, variable_filter: Option<&Vec<String>>) -> Result<()> {
441        let content = fs::read_to_string(path)?;
442
443        for line in content.lines() {
444            let line = line.trim();
445            if line.is_empty() || line.starts_with('#') {
446                continue;
447            }
448
449            if let Some((key, value)) = line.split_once('=') {
450                let key = key.trim();
451                let value = value.trim().trim_matches('"').trim_matches('\'');
452
453                // Apply filter if specified
454                if let Some(filter) = variable_filter {
455                    if !filter.iter().any(|f| key.contains(f)) {
456                        continue;
457                    }
458                }
459
460                manager.set(key, value, true)?;
461            }
462        }
463
464        Ok(())
465    }
466
467    fn load_yaml_file(path: &Path, manager: &mut EnvVarManager, variable_filter: Option<&Vec<String>>) -> Result<()> {
468        let content = fs::read_to_string(path)?;
469        let yaml: serde_yaml::Value = serde_yaml::from_str(&content)?;
470
471        if let serde_yaml::Value::Mapping(map) = yaml {
472            for (key, value) in map {
473                if let (Some(key_str), Some(value_str)) = (key.as_str(), value.as_str()) {
474                    // Apply filter if specified
475                    if let Some(filter) = variable_filter {
476                        if !filter.iter().any(|f| key_str.contains(f)) {
477                            continue;
478                        }
479                    }
480
481                    manager.set(key_str, value_str, true)?;
482                }
483            }
484        }
485
486        Ok(())
487    }
488
489    fn load_json_file(path: &Path, manager: &mut EnvVarManager, variable_filter: Option<&Vec<String>>) -> Result<()> {
490        let content = fs::read_to_string(path)?;
491        let json: serde_json::Value = serde_json::from_str(&content)?;
492
493        if let serde_json::Value::Object(map) = json {
494            for (key, value) in map {
495                if let serde_json::Value::String(value_str) = value {
496                    // Apply filter if specified
497                    if let Some(filter) = variable_filter {
498                        if !filter.iter().any(|f| key.contains(f)) {
499                            continue;
500                        }
501                    }
502
503                    manager.set(&key, &value_str, true)?;
504                }
505            }
506        }
507
508        Ok(())
509    }
510
511    fn start_system_monitor(&mut self) {
512        let manager = Arc::clone(&self.manager);
513        let config = self.config.clone();
514        let _change_log = Arc::clone(&self.change_log);
515        let variable_filter = self.variable_filter.clone();
516        let output_file = self.output_file.clone();
517
518        thread::spawn(move || {
519            let mut last_snapshot = HashMap::new();
520
521            loop {
522                thread::sleep(Duration::from_secs(1));
523
524                manager.lock().unwrap().load_all().ok();
525
526                let current_snapshot: HashMap<String, String> = manager
527                    .lock()
528                    .unwrap()
529                    .list()
530                    .iter()
531                    .filter(|v| {
532                        variable_filter
533                            .as_ref()
534                            .is_none_or(|filter| filter.iter().any(|f| v.name.contains(f)))
535                    })
536                    .map(|v| (v.name.clone(), v.value.clone()))
537                    .collect();
538
539                // Check for changes and write to file if needed
540                if matches!(config.mode, SyncMode::SystemToFile | SyncMode::Bidirectional) {
541                    if let Some(ref output) = output_file {
542                        let mut changed = false;
543
544                        for (name, value) in &current_snapshot {
545                            if last_snapshot.get(name) != Some(value) {
546                                changed = true;
547                                if config.log_changes {
548                                    println!("🔄 System change detected: {name} changed");
549                                }
550                            }
551                        }
552
553                        // Check for deletions
554                        for name in last_snapshot.keys() {
555                            if !current_snapshot.contains_key(name) {
556                                changed = true;
557                                if config.log_changes {
558                                    println!("❌ System change detected: {name} deleted");
559                                }
560                            }
561                        }
562
563                        if changed {
564                            // Write to output file
565                            let mut content = String::new();
566                            #[allow(clippy::format_push_string)]
567                            for (name, value) in &current_snapshot {
568                                content.push_str(&format!("{name}={value}\n"));
569                            }
570
571                            if let Err(e) = fs::write(output, &content) {
572                                eprintln!("Failed to write to output file: {e}");
573                            } else if config.log_changes {
574                                println!("💾 Updated output file");
575                            }
576                        }
577                    }
578                }
579
580                last_snapshot = current_snapshot;
581            }
582        });
583    }
584
585    fn matches_patterns(path: &Path, patterns: &[String]) -> bool {
586        let file_name = match path.file_name() {
587            Some(name) => name.to_string_lossy(),
588            None => return false,
589        };
590
591        patterns.iter().any(|pattern| {
592            if pattern.contains('*') {
593                let regex_pattern = pattern.replace('.', r"\.").replace('*', ".*");
594                if let Ok(re) = regex::Regex::new(&format!("^{regex_pattern}$")) {
595                    return re.is_match(&file_name);
596                }
597            }
598            &file_name == pattern
599        })
600    }
601
602    fn log_change(change_log: &Arc<Mutex<Vec<ChangeEvent>>>, path: PathBuf, change_type: ChangeType, details: String) {
603        let event = ChangeEvent {
604            timestamp: chrono::Utc::now(),
605            path,
606            change_type,
607            details,
608        };
609
610        let mut log = change_log.lock().expect("Failed to lock change log");
611        log.push(event);
612
613        // Keep only last 1000 events
614        if log.len() > 1000 {
615            log.drain(0..100);
616        }
617    }
618
619    /// Returns a clone of the change log containing all recorded change events.
620    ///
621    /// # Panics
622    ///
623    /// Panics if the change log mutex is poisoned (i.e., another thread panicked while holding the lock).
624    #[must_use]
625    pub fn get_change_log(&self) -> Vec<ChangeEvent> {
626        self.change_log.lock().expect("Failed to lock change log").clone()
627    }
628
629    /// Exports the change log to a JSON file at the specified path.
630    ///
631    /// # Errors
632    ///
633    /// This function will return an error if:
634    /// - The change log cannot be serialized to JSON
635    /// - The file cannot be written to the specified path
636    pub fn export_change_log(&self, path: &Path) -> Result<()> {
637        let log = self.get_change_log();
638        let json = serde_json::to_string_pretty(&log)?;
639        fs::write(path, json)?;
640        Ok(())
641    }
642
643    pub fn set_variable_filter(&mut self, vars: Vec<String>) {
644        self.variable_filter = Some(vars);
645    }
646
647    /// Set output file for system-to-file sync
648    pub fn set_output_file(&mut self, path: PathBuf) {
649        self.output_file = Some(path);
650    }
651}