envx_cli/
monitor.rs

1use chrono::Local;
2use clap::Args;
3use clap::ValueEnum;
4use color_eyre::Result;
5use comfy_table::Table;
6use comfy_table::presets::UTF8_FULL;
7use envx_core::EnvVarManager;
8use envx_core::EnvVarSource;
9use serde::Serialize;
10use std::collections::HashMap;
11use std::path::PathBuf;
12use std::time::Duration;
13
14#[derive(Debug, Clone, ValueEnum)]
15pub enum OutputFormat {
16    /// Live terminal output
17    Live,
18    /// Compact output
19    Compact,
20    /// JSON lines format
21    JsonLines,
22}
23
24#[derive(Debug, Clone, ValueEnum)]
25pub enum SourceFilter {
26    #[value(name = "system")]
27    System,
28    #[value(name = "user")]
29    User,
30    #[value(name = "process")]
31    Process,
32    #[value(name = "shell")]
33    Shell,
34}
35
36impl From<SourceFilter> for EnvVarSource {
37    fn from(filter: SourceFilter) -> Self {
38        match filter {
39            SourceFilter::System => EnvVarSource::System,
40            SourceFilter::User => EnvVarSource::User,
41            SourceFilter::Process => EnvVarSource::Process,
42            SourceFilter::Shell => EnvVarSource::Shell,
43        }
44    }
45}
46
47#[derive(Args)]
48pub struct MonitorArgs {
49    /// Variables to monitor (monitor all if not specified)
50    #[arg(value_name = "VARIABLE")]
51    pub vars: Vec<String>,
52
53    /// Log file path
54    #[arg(short, long)]
55    pub log: Option<PathBuf>,
56
57    /// Show only changes (hide unchanged variables)
58    #[arg(long)]
59    pub changes_only: bool,
60
61    /// Filter by source
62    #[arg(short, long, value_enum)]
63    pub source: Option<SourceFilter>,
64
65    /// Output format
66    #[arg(short, long, value_enum, default_value = "live")]
67    pub format: OutputFormat,
68
69    /// Check interval in seconds
70    #[arg(long, default_value = "2")]
71    pub interval: u64,
72
73    /// Show initial state
74    #[arg(long)]
75    pub show_initial: bool,
76
77    /// Export report on exit
78    #[arg(long)]
79    pub export_report: Option<PathBuf>,
80}
81
82struct MonitorState {
83    initial: HashMap<String, String>,
84    current: HashMap<String, String>,
85    changes: Vec<ChangeRecord>,
86    start_time: chrono::DateTime<Local>,
87}
88
89#[derive(Debug, Clone, Serialize)]
90struct ChangeRecord {
91    timestamp: chrono::DateTime<Local>,
92    variable: String,
93    change_type: String,
94    old_value: Option<String>,
95    new_value: Option<String>,
96}
97
98/// Handles the monitor command to track environment variable changes.
99///
100/// # Errors
101///
102/// Returns an error if:
103/// - Failed to load environment variables
104/// - Failed to set up Ctrl+C handler
105/// - Failed to write to log file (if specified)
106/// - Failed to export report (if specified)
107pub fn handle_monitor(args: MonitorArgs) -> Result<()> {
108    let mut manager = EnvVarManager::new();
109    manager.load_all()?;
110
111    let mut state = MonitorState {
112        initial: collect_variables(&manager, &args),
113        current: HashMap::new(),
114        changes: Vec::new(),
115        start_time: Local::now(),
116    };
117
118    print_monitor_header(&args);
119
120    if args.show_initial {
121        print_initial_state(&state.initial);
122    }
123
124    // Set up Ctrl+C handler
125    let running = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
126    let r = running.clone();
127
128    ctrlc::set_handler(move || {
129        r.store(false, std::sync::atomic::Ordering::SeqCst);
130    })?;
131
132    // Monitoring loop
133    while running.load(std::sync::atomic::Ordering::SeqCst) {
134        std::thread::sleep(Duration::from_secs(args.interval));
135
136        let mut current_manager = EnvVarManager::new();
137        current_manager.load_all()?;
138
139        state.current = collect_variables(&current_manager, &args);
140
141        let changes = detect_changes(&state);
142
143        if !changes.is_empty() || !args.changes_only {
144            display_changes(&changes, &args);
145
146            // Log changes
147            for change in changes {
148                state.changes.push(change.clone());
149
150                if let Some(log_path) = &args.log {
151                    log_change(log_path, &change)?;
152                }
153            }
154        }
155
156        // Update state for next iteration
157        for (name, value) in &state.current {
158            state.initial.insert(name.clone(), value.clone());
159        }
160    }
161
162    // Generate final report if requested
163    if let Some(report_path) = args.export_report {
164        export_report(&state, &report_path)?;
165        println!("\nšŸ“Š Report exported to: {}", report_path.display());
166    }
167
168    print_monitor_summary(&state);
169
170    Ok(())
171}
172
173fn collect_variables(manager: &EnvVarManager, args: &MonitorArgs) -> HashMap<String, String> {
174    manager
175        .list()
176        .into_iter()
177        .filter(|var| {
178            // Filter by variable names if specified
179            (args.vars.is_empty() || args.vars.iter().any(|v| var.name.contains(v))) &&
180            // Filter by source if specified
181            (args.source.is_none() || args.source.as_ref().map(|s| EnvVarSource::from(s.clone())) == Some(var.source.clone()))
182        })
183        .map(|var| (var.name.clone(), var.value.clone()))
184        .collect()
185}
186
187fn detect_changes(state: &MonitorState) -> Vec<ChangeRecord> {
188    let mut changes = Vec::new();
189    let timestamp = Local::now();
190
191    // Check for modifications and additions
192    for (name, value) in &state.current {
193        match state.initial.get(name) {
194            Some(old_value) if old_value != value => {
195                changes.push(ChangeRecord {
196                    timestamp,
197                    variable: name.clone(),
198                    change_type: "modified".to_string(),
199                    old_value: Some(old_value.clone()),
200                    new_value: Some(value.clone()),
201                });
202            }
203            None => {
204                changes.push(ChangeRecord {
205                    timestamp,
206                    variable: name.clone(),
207                    change_type: "added".to_string(),
208                    old_value: None,
209                    new_value: Some(value.clone()),
210                });
211            }
212            _ => {} // No change
213        }
214    }
215
216    // Check for deletions
217    for (name, value) in &state.initial {
218        if !state.current.contains_key(name) {
219            changes.push(ChangeRecord {
220                timestamp,
221                variable: name.clone(),
222                change_type: "deleted".to_string(),
223                old_value: Some(value.clone()),
224                new_value: None,
225            });
226        }
227    }
228
229    changes
230}
231
232fn display_changes(changes: &[ChangeRecord], args: &MonitorArgs) {
233    match args.format {
234        OutputFormat::Live => {
235            for change in changes {
236                let time = change.timestamp.format("%H:%M:%S");
237                match change.change_type.as_str() {
238                    "added" => {
239                        println!(
240                            "[{}] āž• {} = '{}'",
241                            time,
242                            change.variable,
243                            change.new_value.as_ref().unwrap_or(&String::new())
244                        );
245                    }
246                    "modified" => {
247                        println!(
248                            "[{}] šŸ”„ {} changed from '{}' to '{}'",
249                            time,
250                            change.variable,
251                            change.old_value.as_ref().unwrap_or(&String::new()),
252                            change.new_value.as_ref().unwrap_or(&String::new())
253                        );
254                    }
255                    "deleted" => {
256                        println!(
257                            "[{}] āŒ {} deleted (was: '{}')",
258                            time,
259                            change.variable,
260                            change.old_value.as_ref().unwrap_or(&String::new())
261                        );
262                    }
263                    _ => {}
264                }
265            }
266        }
267        OutputFormat::Compact => {
268            for change in changes {
269                println!(
270                    "{} {} {}",
271                    change.timestamp.format("%Y-%m-%d %H:%M:%S"),
272                    change.change_type.to_uppercase(),
273                    change.variable
274                );
275            }
276        }
277        OutputFormat::JsonLines => {
278            for change in changes {
279                if let Ok(json) = serde_json::to_string(change) {
280                    println!("{json}");
281                }
282            }
283        }
284    }
285}
286
287fn log_change(path: &PathBuf, change: &ChangeRecord) -> Result<()> {
288    use std::fs::OpenOptions;
289    use std::io::Write;
290
291    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
292
293    writeln!(file, "{}", serde_json::to_string(change)?)?;
294    Ok(())
295}
296
297fn print_monitor_header(args: &MonitorArgs) {
298    println!("šŸ“Š Environment Variable Monitor");
299    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
300
301    if args.vars.is_empty() {
302        println!("Monitoring: All variables");
303    } else {
304        println!("Monitoring: {}", args.vars.join(", "));
305    }
306
307    if let Some(source) = &args.source {
308        println!("Source filter: {source:?}");
309    }
310
311    println!("Check interval: {} seconds", args.interval);
312    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
313    println!("Press Ctrl+C to stop\n");
314}
315
316fn print_initial_state(vars: &HashMap<String, String>) {
317    if vars.is_empty() {
318        println!("No variables match the criteria\n");
319        return;
320    }
321
322    let mut table = Table::new();
323    table.load_preset(UTF8_FULL);
324    table.set_header(vec!["Variable", "Initial Value"]);
325
326    for (name, value) in vars {
327        let display_value = if value.len() > 50 {
328            format!("{}...", &value[..47])
329        } else {
330            value.clone()
331        };
332        table.add_row(vec![name.clone(), display_value]);
333    }
334
335    println!("Initial State:\n{table}\n");
336}
337
338fn print_monitor_summary(state: &MonitorState) {
339    let duration = Local::now().signed_duration_since(state.start_time);
340
341    println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
342    println!("šŸ“Š Monitoring Summary");
343    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
344    println!("Duration: {}", format_duration(duration));
345    println!("Total changes: {}", state.changes.len());
346
347    let mut added = 0;
348    let mut modified = 0;
349    let mut deleted = 0;
350
351    for change in &state.changes {
352        match change.change_type.as_str() {
353            "added" => added += 1,
354            "modified" => modified += 1,
355            "deleted" => deleted += 1,
356            _ => {}
357        }
358    }
359
360    println!("  āž• Added: {added}");
361    println!("  šŸ”„ Modified: {modified}");
362    println!("  āŒ Deleted: {deleted}");
363}
364
365fn format_duration(duration: chrono::Duration) -> String {
366    let hours = duration.num_hours();
367    let minutes = duration.num_minutes() % 60;
368    let seconds = duration.num_seconds() % 60;
369
370    if hours > 0 {
371        format!("{hours}h {minutes}m {seconds}s")
372    } else if minutes > 0 {
373        format!("{minutes}m {seconds}s")
374    } else {
375        format!("{seconds}s")
376    }
377}
378
379fn export_report(state: &MonitorState, path: &PathBuf) -> Result<()> {
380    #[derive(Serialize)]
381    struct Report {
382        start_time: chrono::DateTime<Local>,
383        end_time: chrono::DateTime<Local>,
384        duration_seconds: i64,
385        total_changes: usize,
386        changes_by_type: HashMap<String, usize>,
387        changes: Vec<ChangeRecord>,
388    }
389
390    let mut changes_by_type = HashMap::new();
391    for change in &state.changes {
392        *changes_by_type.entry(change.change_type.clone()).or_insert(0) += 1;
393    }
394
395    let report = Report {
396        start_time: state.start_time,
397        end_time: Local::now(),
398        duration_seconds: Local::now().signed_duration_since(state.start_time).num_seconds(),
399        total_changes: state.changes.len(),
400        changes_by_type,
401        changes: state.changes.clone(),
402    };
403
404    let json = serde_json::to_string_pretty(&report)?;
405    std::fs::write(path, json)?;
406
407    Ok(())
408}
409
410// Add this at the end of the file
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use envx_core::{EnvVar, EnvVarManager};
416    use std::collections::HashMap;
417
418    // Helper function to create a test environment variable
419    fn create_test_env_var(name: &str, value: &str, source: EnvVarSource) -> EnvVar {
420        EnvVar {
421            name: name.to_string(),
422            value: value.to_string(),
423            source,
424            modified: chrono::Utc::now(),
425            original_value: None,
426        }
427    }
428
429    // Helper function to create a test manager with predefined variables
430    fn create_test_manager() -> EnvVarManager {
431        let mut manager = EnvVarManager::new();
432
433        // Add test variables with different sources
434        manager.vars.insert(
435            "SYSTEM_VAR".to_string(),
436            create_test_env_var("SYSTEM_VAR", "system_value", EnvVarSource::System),
437        );
438        manager.vars.insert(
439            "USER_VAR".to_string(),
440            create_test_env_var("USER_VAR", "user_value", EnvVarSource::User),
441        );
442        manager.vars.insert(
443            "PROCESS_VAR".to_string(),
444            create_test_env_var("PROCESS_VAR", "process_value", EnvVarSource::Process),
445        );
446        manager.vars.insert(
447            "SHELL_VAR".to_string(),
448            create_test_env_var("SHELL_VAR", "shell_value", EnvVarSource::Shell),
449        );
450        manager.vars.insert(
451            "APP_VAR".to_string(),
452            create_test_env_var(
453                "APP_VAR",
454                "app_value",
455                EnvVarSource::Application("test_app".to_string()),
456            ),
457        );
458        manager.vars.insert(
459            "TEST_API_KEY".to_string(),
460            create_test_env_var("TEST_API_KEY", "secret123", EnvVarSource::User),
461        );
462        manager.vars.insert(
463            "DATABASE_URL".to_string(),
464            create_test_env_var("DATABASE_URL", "postgres://localhost:5432", EnvVarSource::User),
465        );
466
467        manager
468    }
469
470    #[test]
471    fn test_collect_variables_all() {
472        let manager = create_test_manager();
473        let args = MonitorArgs {
474            vars: vec![],
475            log: None,
476            changes_only: false,
477            source: None,
478            format: OutputFormat::Live,
479            interval: 2,
480            show_initial: false,
481            export_report: None,
482        };
483
484        let result = collect_variables(&manager, &args);
485
486        // Should collect all variables
487        assert_eq!(result.len(), 7);
488        assert_eq!(result.get("SYSTEM_VAR"), Some(&"system_value".to_string()));
489        assert_eq!(result.get("USER_VAR"), Some(&"user_value".to_string()));
490        assert_eq!(result.get("PROCESS_VAR"), Some(&"process_value".to_string()));
491        assert_eq!(result.get("SHELL_VAR"), Some(&"shell_value".to_string()));
492        assert_eq!(result.get("APP_VAR"), Some(&"app_value".to_string()));
493        assert_eq!(result.get("TEST_API_KEY"), Some(&"secret123".to_string()));
494        assert_eq!(
495            result.get("DATABASE_URL"),
496            Some(&"postgres://localhost:5432".to_string())
497        );
498    }
499
500    #[test]
501    fn test_collect_variables_with_name_filter() {
502        let manager = create_test_manager();
503        let args = MonitorArgs {
504            vars: vec!["API".to_string(), "DATABASE".to_string()],
505            log: None,
506            changes_only: false,
507            source: None,
508            format: OutputFormat::Live,
509            interval: 2,
510            show_initial: false,
511            export_report: None,
512        };
513
514        let result = collect_variables(&manager, &args);
515
516        // Should only collect variables containing "API" or "DATABASE"
517        assert_eq!(result.len(), 2);
518        assert_eq!(result.get("TEST_API_KEY"), Some(&"secret123".to_string()));
519        assert_eq!(
520            result.get("DATABASE_URL"),
521            Some(&"postgres://localhost:5432".to_string())
522        );
523        assert!(!result.contains_key("SYSTEM_VAR"));
524    }
525
526    #[test]
527    fn test_collect_variables_with_source_filter() {
528        let manager = create_test_manager();
529        let args = MonitorArgs {
530            vars: vec![],
531            log: None,
532            changes_only: false,
533            source: Some(SourceFilter::User),
534            format: OutputFormat::Live,
535            interval: 2,
536            show_initial: false,
537            export_report: None,
538        };
539
540        let result = collect_variables(&manager, &args);
541
542        // Should only collect User source variables
543        assert_eq!(result.len(), 3);
544        assert_eq!(result.get("USER_VAR"), Some(&"user_value".to_string()));
545        assert_eq!(result.get("TEST_API_KEY"), Some(&"secret123".to_string()));
546        assert_eq!(
547            result.get("DATABASE_URL"),
548            Some(&"postgres://localhost:5432".to_string())
549        );
550        assert!(!result.contains_key("SYSTEM_VAR"));
551        assert!(!result.contains_key("PROCESS_VAR"));
552    }
553
554    #[test]
555    fn test_collect_variables_with_combined_filters() {
556        let manager = create_test_manager();
557        let args = MonitorArgs {
558            vars: vec!["VAR".to_string()],
559            log: None,
560            changes_only: false,
561            source: Some(SourceFilter::System),
562            format: OutputFormat::Live,
563            interval: 2,
564            show_initial: false,
565            export_report: None,
566        };
567
568        let result = collect_variables(&manager, &args);
569
570        // Should only collect System source variables containing "VAR"
571        assert_eq!(result.len(), 1);
572        assert_eq!(result.get("SYSTEM_VAR"), Some(&"system_value".to_string()));
573    }
574
575    #[test]
576    fn test_collect_variables_empty_result() {
577        let manager = create_test_manager();
578        let args = MonitorArgs {
579            vars: vec!["NONEXISTENT".to_string()],
580            log: None,
581            changes_only: false,
582            source: None,
583            format: OutputFormat::Live,
584            interval: 2,
585            show_initial: false,
586            export_report: None,
587        };
588
589        let result = collect_variables(&manager, &args);
590
591        // Should return empty map
592        assert!(result.is_empty());
593    }
594
595    #[test]
596    fn test_detect_changes_no_changes() {
597        let state = MonitorState {
598            initial: HashMap::from([
599                ("VAR1".to_string(), "value1".to_string()),
600                ("VAR2".to_string(), "value2".to_string()),
601            ]),
602            current: HashMap::from([
603                ("VAR1".to_string(), "value1".to_string()),
604                ("VAR2".to_string(), "value2".to_string()),
605            ]),
606            changes: vec![],
607            start_time: Local::now(),
608        };
609
610        let changes = detect_changes(&state);
611
612        // No changes should be detected
613        assert!(changes.is_empty());
614    }
615
616    #[test]
617    fn test_detect_changes_modifications() {
618        let state = MonitorState {
619            initial: HashMap::from([
620                ("VAR1".to_string(), "old_value".to_string()),
621                ("VAR2".to_string(), "value2".to_string()),
622            ]),
623            current: HashMap::from([
624                ("VAR1".to_string(), "new_value".to_string()),
625                ("VAR2".to_string(), "value2".to_string()),
626            ]),
627            changes: vec![],
628            start_time: Local::now(),
629        };
630
631        let changes = detect_changes(&state);
632
633        // Should detect one modification
634        assert_eq!(changes.len(), 1);
635        assert_eq!(changes[0].variable, "VAR1");
636        assert_eq!(changes[0].change_type, "modified");
637        assert_eq!(changes[0].old_value, Some("old_value".to_string()));
638        assert_eq!(changes[0].new_value, Some("new_value".to_string()));
639    }
640
641    #[test]
642    fn test_detect_changes_additions() {
643        let state = MonitorState {
644            initial: HashMap::from([("VAR1".to_string(), "value1".to_string())]),
645            current: HashMap::from([
646                ("VAR1".to_string(), "value1".to_string()),
647                ("VAR2".to_string(), "new_var_value".to_string()),
648                ("VAR3".to_string(), "another_new".to_string()),
649            ]),
650            changes: vec![],
651            start_time: Local::now(),
652        };
653
654        let changes = detect_changes(&state);
655
656        // Should detect two additions
657        assert_eq!(changes.len(), 2);
658
659        let added_vars: Vec<&str> = changes
660            .iter()
661            .filter(|c| c.change_type == "added")
662            .map(|c| c.variable.as_str())
663            .collect();
664
665        assert!(added_vars.contains(&"VAR2"));
666        assert!(added_vars.contains(&"VAR3"));
667
668        for change in changes {
669            assert_eq!(change.change_type, "added");
670            assert!(change.old_value.is_none());
671            assert!(change.new_value.is_some());
672        }
673    }
674
675    #[test]
676    fn test_detect_changes_deletions() {
677        let state = MonitorState {
678            initial: HashMap::from([
679                ("VAR1".to_string(), "value1".to_string()),
680                ("VAR2".to_string(), "value2".to_string()),
681                ("VAR3".to_string(), "value3".to_string()),
682            ]),
683            current: HashMap::from([("VAR2".to_string(), "value2".to_string())]),
684            changes: vec![],
685            start_time: Local::now(),
686        };
687
688        let changes = detect_changes(&state);
689
690        // Should detect two deletions
691        assert_eq!(changes.len(), 2);
692
693        let deleted_vars: Vec<&str> = changes
694            .iter()
695            .filter(|c| c.change_type == "deleted")
696            .map(|c| c.variable.as_str())
697            .collect();
698
699        assert!(deleted_vars.contains(&"VAR1"));
700        assert!(deleted_vars.contains(&"VAR3"));
701
702        for change in changes {
703            if change.change_type == "deleted" {
704                assert!(change.old_value.is_some());
705                assert!(change.new_value.is_none());
706            }
707        }
708    }
709
710    #[test]
711    fn test_detect_changes_mixed() {
712        let state = MonitorState {
713            initial: HashMap::from([
714                ("MODIFIED".to_string(), "old".to_string()),
715                ("DELETED".to_string(), "will_be_removed".to_string()),
716                ("UNCHANGED".to_string(), "same".to_string()),
717            ]),
718            current: HashMap::from([
719                ("MODIFIED".to_string(), "new".to_string()),
720                ("UNCHANGED".to_string(), "same".to_string()),
721                ("ADDED".to_string(), "brand_new".to_string()),
722            ]),
723            changes: vec![],
724            start_time: Local::now(),
725        };
726
727        let changes = detect_changes(&state);
728
729        // Should detect 3 changes: 1 modified, 1 added, 1 deleted
730        assert_eq!(changes.len(), 3);
731
732        let change_map: HashMap<String, &ChangeRecord> = changes.iter().map(|c| (c.variable.clone(), c)).collect();
733
734        // Check modified
735        let modified = change_map.get("MODIFIED").unwrap();
736        assert_eq!(modified.change_type, "modified");
737        assert_eq!(modified.old_value, Some("old".to_string()));
738        assert_eq!(modified.new_value, Some("new".to_string()));
739
740        // Check added
741        let added = change_map.get("ADDED").unwrap();
742        assert_eq!(added.change_type, "added");
743        assert!(added.old_value.is_none());
744        assert_eq!(added.new_value, Some("brand_new".to_string()));
745
746        // Check deleted
747        let deleted = change_map.get("DELETED").unwrap();
748        assert_eq!(deleted.change_type, "deleted");
749        assert_eq!(deleted.old_value, Some("will_be_removed".to_string()));
750        assert!(deleted.new_value.is_none());
751    }
752
753    #[test]
754    fn test_detect_changes_empty_states() {
755        // Test with empty initial state
756        let state = MonitorState {
757            initial: HashMap::new(),
758            current: HashMap::from([
759                ("NEW1".to_string(), "value1".to_string()),
760                ("NEW2".to_string(), "value2".to_string()),
761            ]),
762            changes: vec![],
763            start_time: Local::now(),
764        };
765
766        let changes = detect_changes(&state);
767        assert_eq!(changes.len(), 2);
768        assert!(changes.iter().all(|c| c.change_type == "added"));
769
770        // Test with empty current state
771        let state2 = MonitorState {
772            initial: HashMap::from([
773                ("OLD1".to_string(), "value1".to_string()),
774                ("OLD2".to_string(), "value2".to_string()),
775            ]),
776            current: HashMap::new(),
777            changes: vec![],
778            start_time: Local::now(),
779        };
780
781        let changes2 = detect_changes(&state2);
782        assert_eq!(changes2.len(), 2);
783        assert!(changes2.iter().all(|c| c.change_type == "deleted"));
784    }
785
786    #[test]
787    fn test_detect_changes_special_characters() {
788        let state = MonitorState {
789            initial: HashMap::from([
790                ("PATH/WITH/SLASH".to_string(), "value1".to_string()),
791                ("VAR=WITH=EQUALS".to_string(), "value2".to_string()),
792                ("UNICODE_变量".to_string(), "旧值".to_string()),
793            ]),
794            current: HashMap::from([
795                ("PATH/WITH/SLASH".to_string(), "value1_modified".to_string()),
796                ("VAR=WITH=EQUALS".to_string(), "value2".to_string()),
797                ("UNICODE_变量".to_string(), "新值".to_string()),
798            ]),
799            changes: vec![],
800            start_time: Local::now(),
801        };
802
803        let changes = detect_changes(&state);
804
805        assert_eq!(changes.len(), 2);
806
807        let unicode_change = changes.iter().find(|c| c.variable == "UNICODE_变量").unwrap();
808        assert_eq!(unicode_change.old_value, Some("旧值".to_string()));
809        assert_eq!(unicode_change.new_value, Some("新值".to_string()));
810    }
811
812    #[test]
813    fn test_detect_changes_case_sensitive() {
814        let state = MonitorState {
815            initial: HashMap::from([
816                ("lowercase".to_string(), "value1".to_string()),
817                ("UPPERCASE".to_string(), "value2".to_string()),
818            ]),
819            current: HashMap::from([
820                ("lowercase".to_string(), "value1".to_string()),
821                ("UPPERCASE".to_string(), "value2".to_string()),
822                ("Lowercase".to_string(), "different".to_string()), // Different case
823            ]),
824            changes: vec![],
825            start_time: Local::now(),
826        };
827
828        let changes = detect_changes(&state);
829
830        // Should detect the new variable with different case as an addition
831        assert_eq!(changes.len(), 1);
832        assert_eq!(changes[0].variable, "Lowercase");
833        assert_eq!(changes[0].change_type, "added");
834    }
835
836    #[test]
837    fn test_detect_changes_empty_values() {
838        let state = MonitorState {
839            initial: HashMap::from([
840                ("EMPTY_TO_VALUE".to_string(), String::new()),
841                ("VALUE_TO_EMPTY".to_string(), "something".to_string()),
842                ("EMPTY_TO_EMPTY".to_string(), String::new()),
843            ]),
844            current: HashMap::from([
845                ("EMPTY_TO_VALUE".to_string(), "now_has_value".to_string()),
846                ("VALUE_TO_EMPTY".to_string(), String::new()),
847                ("EMPTY_TO_EMPTY".to_string(), String::new()),
848            ]),
849            changes: vec![],
850            start_time: Local::now(),
851        };
852
853        let changes = detect_changes(&state);
854
855        // Should detect 2 changes (empty to empty is not a change)
856        assert_eq!(changes.len(), 2);
857
858        let empty_to_value = changes.iter().find(|c| c.variable == "EMPTY_TO_VALUE").unwrap();
859        assert_eq!(empty_to_value.old_value, Some(String::new()));
860        assert_eq!(empty_to_value.new_value, Some("now_has_value".to_string()));
861
862        let value_to_empty = changes.iter().find(|c| c.variable == "VALUE_TO_EMPTY").unwrap();
863        assert_eq!(value_to_empty.old_value, Some("something".to_string()));
864        assert_eq!(value_to_empty.new_value, Some(String::new()));
865    }
866
867    #[test]
868    fn test_detect_changes_timestamp_consistency() {
869        let state = MonitorState {
870            initial: HashMap::from([("VAR1".to_string(), "old".to_string())]),
871            current: HashMap::from([
872                ("VAR1".to_string(), "new".to_string()),
873                ("VAR2".to_string(), "added".to_string()),
874            ]),
875            changes: vec![],
876            start_time: Local::now(),
877        };
878
879        let before = Local::now();
880        let changes = detect_changes(&state);
881        let after = Local::now();
882
883        // All changes should have the same timestamp
884        assert!(changes.len() >= 2);
885        let first_timestamp = changes[0].timestamp;
886        assert!(changes.iter().all(|c| c.timestamp == first_timestamp));
887
888        // Timestamp should be within the test execution window
889        assert!(first_timestamp >= before && first_timestamp <= after);
890    }
891}