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}