envx_cli/
cli.rs

1use crate::MonitorArgs;
2use crate::ProfileArgs;
3use crate::ProjectArgs;
4use crate::RenameArgs;
5use crate::SnapshotArgs;
6use crate::WatchArgs;
7use crate::handle_find_replace;
8use crate::handle_list_command;
9use crate::handle_path_command;
10use crate::handle_profile;
11use crate::handle_project;
12use crate::handle_rename;
13use crate::handle_replace;
14use crate::handle_snapshot;
15use crate::handle_watch;
16use crate::monitor::handle_monitor;
17use crate::replace::FindReplaceArgs;
18use crate::replace::ReplaceArgs;
19use clap::{Parser, Subcommand};
20use color_eyre::Result;
21use color_eyre::eyre::eyre;
22use envx_core::{Analyzer, EnvVarManager, ExportFormat, Exporter, ImportFormat, Importer};
23use std::io::Write;
24use std::path::Path;
25#[derive(Parser)]
26#[command(name = "envx")]
27#[command(about = "System Environment Variable Manager")]
28#[command(version)]
29pub struct Cli {
30    #[command(subcommand)]
31    pub command: Commands,
32}
33
34#[derive(Subcommand)]
35pub enum Commands {
36    /// List environment variables
37    List {
38        /// Filter by source (system, user, process, shell)
39        #[arg(short, long)]
40        source: Option<String>,
41
42        /// Search query
43        #[arg(short = 'q', long)]
44        query: Option<String>,
45
46        /// Output format (json, table, simple, compact)
47        #[arg(short, long, default_value = "table")]
48        format: String,
49
50        /// Sort by (name, value, source)
51        #[arg(long, default_value = "name")]
52        sort: String,
53
54        /// Show only variable names
55        #[arg(long)]
56        names_only: bool,
57
58        /// Limit output to N entries
59        #[arg(short, long)]
60        limit: Option<usize>,
61
62        /// Show statistics summary
63        #[arg(long)]
64        stats: bool,
65    },
66
67    /// Get a specific environment variable
68    Get {
69        /// Variable name or pattern (supports *, ?, and /regex/)
70        /// Examples:
71        ///   envx get PATH           - exact match
72        ///   envx get PATH*          - starts with PATH
73        ///   envx get *PATH          - ends with PATH
74        ///   envx get *PATH*         - contains PATH
75        ///   envx get P?TH           - P followed by any char, then TH
76        ///   envx get /^JAVA.*/      - regex pattern
77        pattern: String,
78
79        /// Output format (simple, detailed, json)
80        #[arg(short, long, default_value = "simple")]
81        format: String,
82    },
83
84    /// Set an environment variable
85    Set {
86        /// Variable name
87        name: String,
88
89        /// Variable value
90        value: String,
91
92        /// Set as temporary (only for current session)
93        #[arg(short, long)]
94        temporary: bool,
95    },
96
97    /// Delete environment variable(s)
98    Delete {
99        /// Variable name or pattern
100        pattern: String,
101
102        /// Force deletion without confirmation
103        #[arg(short, long)]
104        force: bool,
105    },
106
107    /// Analyze environment variables
108    Analyze {
109        /// Type of analysis (duplicates, invalid)
110        #[arg(short, long, default_value = "all")]
111        analysis_type: String,
112    },
113
114    /// Launch the TUI
115    #[command(visible_alias = "ui")]
116    Tui,
117
118    /// Manage PATH variable
119    Path {
120        #[command(subcommand)]
121        action: Option<PathAction>,
122
123        /// Check if all paths exist
124        #[arg(short, long)]
125        check: bool,
126
127        /// Target PATH variable (PATH, Path, or custom like PYTHONPATH)
128        #[arg(short = 'v', long, default_value = "PATH")]
129        var: String,
130
131        /// Apply changes permanently
132        #[arg(short = 'p', long)]
133        permanent: bool,
134    },
135
136    /// Export environment variables to a file
137    Export {
138        /// Output file path
139        file: String,
140
141        /// Variable names or patterns to export (exports all if not specified)
142        #[arg(short = 'v', long)]
143        vars: Vec<String>,
144
145        /// Export format (auto-detect from extension, or: env, json, yaml, txt)
146        #[arg(short, long)]
147        format: Option<String>,
148
149        /// Include only specific sources (system, user, process, shell)
150        #[arg(short, long)]
151        source: Option<String>,
152
153        /// Include metadata (source, modified time)
154        #[arg(short, long)]
155        metadata: bool,
156
157        /// Overwrite existing file without confirmation
158        #[arg(long)]
159        force: bool,
160    },
161
162    /// Import environment variables from a file
163    Import {
164        /// Input file path
165        file: String,
166
167        /// Variable names or patterns to import (imports all if not specified)
168        #[arg(short = 'v', long)]
169        vars: Vec<String>,
170
171        /// Import format (auto-detect from extension, or: env, json, yaml, txt)
172        #[arg(short, long)]
173        format: Option<String>,
174
175        /// Make imported variables permanent
176        #[arg(short, long)]
177        permanent: bool,
178
179        /// Prefix to add to all imported variable names
180        #[arg(long)]
181        prefix: Option<String>,
182
183        /// Overwrite existing variables without confirmation
184        #[arg(long)]
185        overwrite: bool,
186
187        /// Dry run - show what would be imported without making changes
188        #[arg(short = 'n', long)]
189        dry_run: bool,
190    },
191
192    /// Manage environment snapshots
193    Snapshot(SnapshotArgs),
194
195    /// Manage environment profiles
196    Profile(ProfileArgs),
197
198    /// Manage project-specific configuration
199    Project(ProjectArgs),
200
201    /// Rename environment variables (supports wildcards)
202    Rename(RenameArgs),
203
204    /// Replace environment variable values
205    Replace(ReplaceArgs),
206
207    /// Find and replace text within environment variable values
208    FindReplace(FindReplaceArgs),
209
210    /// Watch files for changes and auto-sync
211    Watch(WatchArgs),
212
213    /// Monitor environment variable changes (read-only)
214    Monitor(MonitorArgs),
215}
216
217#[derive(Subcommand)]
218pub enum PathAction {
219    /// Add a directory to PATH
220    Add {
221        /// Directory to add
222        directory: String,
223
224        /// Add to the beginning of PATH (highest priority)
225        #[arg(short, long)]
226        first: bool,
227
228        /// Create directory if it doesn't exist
229        #[arg(short, long)]
230        create: bool,
231    },
232
233    /// Remove a directory from PATH
234    Remove {
235        /// Directory to remove (supports wildcards)
236        directory: String,
237
238        /// Remove all occurrences (not just first)
239        #[arg(short, long)]
240        all: bool,
241    },
242
243    /// Clean invalid/non-existent entries from PATH
244    Clean {
245        /// Also remove duplicate entries
246        #[arg(short, long)]
247        dedupe: bool,
248
249        /// Dry run - show what would be removed without making changes
250        #[arg(short = 'n', long)]
251        dry_run: bool,
252    },
253
254    /// Remove duplicate entries from PATH
255    Dedupe {
256        /// Keep first occurrence (default is last)
257        #[arg(short, long)]
258        keep_first: bool,
259
260        /// Dry run - show what would be removed
261        #[arg(short = 'n', long)]
262        dry_run: bool,
263    },
264
265    /// Check PATH for issues
266    Check {
267        /// Verbose output
268        #[arg(short, long)]
269        verbose: bool,
270    },
271
272    /// Show PATH entries in order
273    List {
274        /// Show with index numbers
275        #[arg(short, long)]
276        numbered: bool,
277
278        /// Check existence of each path
279        #[arg(short, long)]
280        check: bool,
281    },
282
283    /// Move a PATH entry to a different position
284    Move {
285        /// Path or index to move
286        from: String,
287
288        /// Target position (first, last, or index)
289        to: String,
290    },
291}
292
293/// Execute the CLI command with the given arguments.
294///
295/// # Errors
296///
297/// This function will return an error if:
298/// - Environment variable operations fail (loading, setting, deleting)
299/// - File I/O operations fail (import/export)
300/// - User input cannot be read
301/// - Invalid command arguments are provided
302/// - TUI mode is requested (should be handled by main binary)
303pub fn execute(cli: Cli) -> Result<()> {
304    match cli.command {
305        Commands::List {
306            source,
307            query,
308            format,
309            sort,
310            names_only,
311            limit,
312            stats,
313        } => {
314            handle_list_command(
315                source.as_deref(),
316                query.as_deref(),
317                &format,
318                &sort,
319                names_only,
320                limit,
321                stats,
322            )?;
323        }
324
325        Commands::Get { pattern, format } => {
326            handle_get_command(&pattern, &format)?;
327        }
328
329        Commands::Set { name, value, temporary } => {
330            handle_set_command(&name, &value, temporary)?;
331        }
332
333        Commands::Delete { pattern, force } => {
334            handle_delete_command(&pattern, force)?;
335        }
336
337        Commands::Analyze { analysis_type } => {
338            handle_analyze_command(&analysis_type)?;
339        }
340
341        Commands::Tui => {
342            // Launch the TUI
343            envx_tui::run()?;
344        }
345
346        Commands::Path {
347            action,
348            check,
349            var,
350            permanent,
351        } => {
352            handle_path_command(action, check, &var, permanent)?;
353        }
354
355        Commands::Export {
356            file,
357            vars,
358            format,
359            source,
360            metadata,
361            force,
362        } => {
363            handle_export(&file, &vars, format, source, metadata, force)?;
364        }
365
366        Commands::Import {
367            file,
368            vars,
369            format,
370            permanent,
371            prefix,
372            overwrite,
373            dry_run,
374        } => {
375            handle_import(&file, &vars, format, permanent, prefix.as_ref(), overwrite, dry_run)?;
376        }
377
378        Commands::Snapshot(args) => {
379            handle_snapshot(args)?;
380        }
381        Commands::Profile(args) => {
382            handle_profile(args)?;
383        }
384
385        Commands::Project(args) => {
386            handle_project(args)?;
387        }
388
389        Commands::Rename(args) => {
390            handle_rename(&args)?;
391        }
392
393        Commands::Replace(args) => {
394            handle_replace(&args)?;
395        }
396
397        Commands::FindReplace(args) => {
398            handle_find_replace(&args)?;
399        }
400
401        Commands::Watch(args) => {
402            handle_watch(&args)?;
403        }
404
405        Commands::Monitor(args) => {
406            handle_monitor(args)?;
407        }
408    }
409
410    Ok(())
411}
412
413fn handle_get_command(pattern: &str, format: &str) -> Result<()> {
414    let mut manager = EnvVarManager::new();
415    manager.load_all()?;
416
417    let vars = manager.get_pattern(pattern);
418
419    if vars.is_empty() {
420        eprintln!("No variables found matching pattern: {pattern}");
421        return Ok(());
422    }
423
424    match format {
425        "json" => {
426            println!("{}", serde_json::to_string_pretty(&vars)?);
427        }
428        "detailed" => {
429            for var in vars {
430                println!("Name: {}", var.name);
431                println!("Value: {}", var.value);
432                println!("Source: {:?}", var.source);
433                println!("Modified: {}", var.modified.format("%Y-%m-%d %H:%M:%S"));
434                if let Some(orig) = &var.original_value {
435                    println!("Original: {orig}");
436                }
437                println!("---");
438            }
439        }
440        _ => {
441            for var in vars {
442                println!("{} = {}", var.name, var.value);
443            }
444        }
445    }
446    Ok(())
447}
448
449fn handle_set_command(name: &str, value: &str, temporary: bool) -> Result<()> {
450    let mut manager = EnvVarManager::new();
451    manager.load_all()?;
452
453    let permanent = !temporary;
454
455    manager.set(name, value, permanent)?;
456    if permanent {
457        println!("✅ Set {name} = \"{value}\"");
458        #[cfg(windows)]
459        println!("📝 Note: You may need to restart your terminal for changes to take effect");
460    } else {
461        println!("⚡ Set {name} = \"{value}\" (temporary - current session only)");
462    }
463    Ok(())
464}
465
466fn handle_delete_command(pattern: &str, force: bool) -> Result<()> {
467    let mut manager = EnvVarManager::new();
468    manager.load_all()?;
469
470    // Collect the names to delete first (owned data, not references)
471    let vars_to_delete: Vec<String> = manager
472        .get_pattern(pattern)
473        .into_iter()
474        .map(|v| v.name.clone())
475        .collect();
476
477    if vars_to_delete.is_empty() {
478        eprintln!("No variables found matching pattern: {pattern}");
479        return Ok(());
480    }
481
482    if !force && vars_to_delete.len() > 1 {
483        println!("About to delete {} variables:", vars_to_delete.len());
484        for name in &vars_to_delete {
485            println!("  - {name}");
486        }
487        print!("Continue? [y/N]: ");
488        std::io::stdout().flush()?;
489
490        let mut input = String::new();
491        std::io::stdin().read_line(&mut input)?;
492
493        if !input.trim().eq_ignore_ascii_case("y") {
494            println!("Cancelled.");
495            return Ok(());
496        }
497    }
498
499    // Now we can safely delete since we're not holding any references to manager
500    for name in vars_to_delete {
501        manager.delete(&name)?;
502        println!("Deleted: {name}");
503    }
504    Ok(())
505}
506
507fn handle_analyze_command(analysis_type: &str) -> Result<()> {
508    let mut manager = EnvVarManager::new();
509    manager.load_all()?;
510    let vars = manager.list().into_iter().cloned().collect();
511    let analyzer = Analyzer::new(vars);
512
513    match analysis_type {
514        "duplicates" | "all" => {
515            let duplicates = analyzer.find_duplicates();
516            if !duplicates.is_empty() {
517                println!("Duplicate variables found:");
518                for (name, vars) in duplicates {
519                    println!("  {}: {} instances", name, vars.len());
520                }
521            }
522        }
523        "invalid" => {
524            let validation = analyzer.validate_all();
525            for (name, result) in validation {
526                if !result.valid {
527                    println!("Invalid variable: {name}");
528                    for error in result.errors {
529                        println!("  Error: {error}");
530                    }
531                }
532            }
533        }
534        _ => {}
535    }
536    Ok(())
537}
538
539fn handle_export(
540    file: &str,
541    vars: &[String],
542    format: Option<String>,
543    source: Option<String>,
544    metadata: bool,
545    force: bool,
546) -> Result<()> {
547    // Check if file exists
548    if Path::new(&file).exists() && !force {
549        print!("File '{file}' already exists. Overwrite? [y/N]: ");
550        std::io::stdout().flush()?;
551
552        let mut input = String::new();
553        std::io::stdin().read_line(&mut input)?;
554
555        if !input.trim().eq_ignore_ascii_case("y") {
556            println!("Export cancelled.");
557            return Ok(());
558        }
559    }
560
561    // Load environment variables
562    let mut manager = EnvVarManager::new();
563    manager.load_all()?;
564
565    // Filter variables to export
566    let mut vars_to_export = if vars.is_empty() {
567        manager.list().into_iter().cloned().collect()
568    } else {
569        let mut selected = Vec::new();
570        for pattern in vars {
571            let matched = manager.get_pattern(pattern);
572            selected.extend(matched.into_iter().cloned());
573        }
574        selected
575    };
576
577    // Filter by source if specified
578    if let Some(src) = source {
579        let source_filter = match src.as_str() {
580            "system" => envx_core::EnvVarSource::System,
581            "user" => envx_core::EnvVarSource::User,
582            "process" => envx_core::EnvVarSource::Process,
583            "shell" => envx_core::EnvVarSource::Shell,
584            _ => return Err(eyre!("Invalid source: {}", src)),
585        };
586
587        vars_to_export.retain(|v| v.source == source_filter);
588    }
589
590    if vars_to_export.is_empty() {
591        println!("No variables to export.");
592        return Ok(());
593    }
594
595    // Determine format
596    let export_format = if let Some(fmt) = format {
597        match fmt.as_str() {
598            "env" => ExportFormat::DotEnv,
599            "json" => ExportFormat::Json,
600            "yaml" | "yml" => ExportFormat::Yaml,
601            "txt" | "text" => ExportFormat::Text,
602            "ps1" | "powershell" => ExportFormat::PowerShell,
603            "sh" | "bash" => ExportFormat::Shell,
604            _ => return Err(eyre!("Unsupported format: {}", fmt)),
605        }
606    } else {
607        // Auto-detect from extension
608        ExportFormat::from_extension(file)?
609    };
610
611    // Export
612    let exporter = Exporter::new(vars_to_export, metadata);
613    exporter.export_to_file(file, export_format)?;
614
615    println!("Exported {} variables to '{}'", exporter.count(), file);
616
617    Ok(())
618}
619
620fn handle_import(
621    file: &str,
622    vars: &[String],
623    format: Option<String>,
624    permanent: bool,
625    prefix: Option<&String>,
626    overwrite: bool,
627    dry_run: bool,
628) -> Result<()> {
629    // Check if file exists
630    if !Path::new(&file).exists() {
631        return Err(eyre!("File not found: {}", file));
632    }
633
634    // Determine format
635    let import_format = if let Some(fmt) = format {
636        match fmt.as_str() {
637            "env" => ImportFormat::DotEnv,
638            "json" => ImportFormat::Json,
639            "yaml" | "yml" => ImportFormat::Yaml,
640            "txt" | "text" => ImportFormat::Text,
641            _ => return Err(eyre!("Unsupported format: {}", fmt)),
642        }
643    } else {
644        // Auto-detect from extension
645        ImportFormat::from_extension(file)?
646    };
647
648    // Import variables
649    let mut importer = Importer::new();
650    importer.import_from_file(file, import_format)?;
651
652    // Filter variables if patterns specified
653    if !vars.is_empty() {
654        importer.filter_by_patterns(vars);
655    }
656
657    // Add prefix if specified
658    if let Some(pfx) = &prefix {
659        importer.add_prefix(pfx);
660    }
661
662    // Get variables to import
663    let import_vars = importer.get_variables();
664
665    if import_vars.is_empty() {
666        println!("No variables to import.");
667        return Ok(());
668    }
669
670    // Check for conflicts
671    let mut manager = EnvVarManager::new();
672    manager.load_all()?;
673
674    let mut conflicts = Vec::new();
675    for (name, _) in &import_vars {
676        if manager.get(name).is_some() {
677            conflicts.push(name.clone());
678        }
679    }
680
681    if !conflicts.is_empty() && !overwrite && !dry_run {
682        println!("The following variables already exist:");
683        for name in &conflicts {
684            println!("  - {name}");
685        }
686
687        print!("Overwrite existing variables? [y/N]: ");
688        std::io::stdout().flush()?;
689
690        let mut input = String::new();
691        std::io::stdin().read_line(&mut input)?;
692
693        if !input.trim().eq_ignore_ascii_case("y") {
694            println!("Import cancelled.");
695            return Ok(());
696        }
697    }
698
699    // Preview or apply changes
700    if dry_run {
701        println!("Would import {} variables:", import_vars.len());
702        for (name, value) in &import_vars {
703            let status = if conflicts.contains(name) {
704                " [OVERWRITE]"
705            } else {
706                " [NEW]"
707            };
708            println!(
709                "  {} = {}{}",
710                name,
711                if value.len() > 50 {
712                    format!("{}...", &value[..50])
713                } else {
714                    value.clone()
715                },
716                status
717            );
718        }
719        println!("\n(Dry run - no changes made)");
720    } else {
721        // Apply imports
722        let mut imported = 0;
723        let mut failed = 0;
724
725        for (name, value) in import_vars {
726            match manager.set(&name, &value, permanent) {
727                Ok(()) => imported += 1,
728                Err(e) => {
729                    eprintln!("Failed to import {name}: {e}");
730                    failed += 1;
731                }
732            }
733        }
734
735        println!("Imported {imported} variables");
736        if failed > 0 {
737            println!("Failed to import {failed} variables");
738        }
739    }
740
741    Ok(())
742}