envx_cli/
cli.rs

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