1use ahash::AHashMap as HashMap;
2use chrono::Local;
3use clap::Args;
4use clap::ValueEnum;
5use color_eyre::Result;
6use comfy_table::Table;
7use comfy_table::presets::UTF8_FULL;
8use envx_core::EnvVarManager;
9use envx_core::EnvVarSource;
10use serde::Serialize;
11use std::path::PathBuf;
12use std::time::Duration;
13
14#[derive(Debug, Clone, ValueEnum)]
15pub enum OutputFormat {
16 Live,
18 Compact,
20 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 #[arg(value_name = "VARIABLE")]
51 pub vars: Vec<String>,
52
53 #[arg(short, long)]
55 pub log: Option<PathBuf>,
56
57 #[arg(long)]
59 pub changes_only: bool,
60
61 #[arg(short, long, value_enum)]
63 pub source: Option<SourceFilter>,
64
65 #[arg(short, long, value_enum, default_value = "live")]
67 pub format: OutputFormat,
68
69 #[arg(long, default_value = "2")]
71 pub interval: u64,
72
73 #[arg(long)]
75 pub show_initial: bool,
76
77 #[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
98pub 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 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 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(¤t_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 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 for (name, value) in &state.current {
158 state.initial.insert(name.clone(), value.clone());
159 }
160 }
161
162 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 (args.vars.is_empty() || args.vars.iter().any(|v| var.name.contains(v))) &&
180 (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 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 _ => {} }
214 }
215
216 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#[cfg(test)]
413mod tests {
414 use super::*;
415 use ahash::AHashMap as HashMap;
416 use envx_core::{EnvVar, EnvVarManager};
417
418 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 fn create_test_manager() -> EnvVarManager {
431 let mut manager = EnvVarManager::new();
432
433 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 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 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 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 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 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 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 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 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 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 assert_eq!(changes.len(), 3);
731
732 let change_map: HashMap<String, &ChangeRecord> = changes.iter().map(|c| (c.variable.clone(), c)).collect();
733
734 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 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 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 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 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()), ]),
824 changes: vec![],
825 start_time: Local::now(),
826 };
827
828 let changes = detect_changes(&state);
829
830 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 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 assert!(changes.len() >= 2);
885 let first_timestamp = changes[0].timestamp;
886 assert!(changes.iter().all(|c| c.timestamp == first_timestamp));
887
888 assert!(first_timestamp >= before && first_timestamp <= after);
890 }
891}