envx_cli/
path.rs

1use std::{io::Write, path::Path};
2
3use crate::PathAction;
4use color_eyre::Result;
5use color_eyre::eyre::eyre;
6use envx_core::{EnvVarManager, PathManager};
7
8const COMMON_PATH_VARIABLES: &[&str] = &["PATH", "PYTHONPATH", "CLASSPATH", "LD_LIBRARY_PATH", "LIBRARY_PATH"];
9
10fn get_path_variable_error_message(var: &str, manager: &EnvVarManager) -> String {
11    let mut suggestions = Vec::new();
12
13    // On Windows, check for case-insensitive matches
14    if cfg!(windows) {
15        // Check all environment variables for case-insensitive matches
16        for env_var in manager.list() {
17            if env_var.name.to_lowercase() == var.to_lowercase() && env_var.name != var {
18                suggestions.push(env_var.name.clone());
19            }
20        }
21    }
22
23    // Also check common path variables for typos/case issues
24    for &common_var in COMMON_PATH_VARIABLES {
25        if common_var.to_lowercase() == var.to_lowercase()
26            && common_var != var
27            && !suggestions.contains(&common_var.to_string())
28        {
29            suggestions.push(common_var.to_string());
30        }
31    }
32
33    let os_info = if cfg!(windows) {
34        "On Windows, PATH variable names are case-insensitive."
35    } else {
36        "On Unix/Linux systems, PATH variable names are case-sensitive."
37    };
38
39    if suggestions.is_empty() {
40        format!(
41            "Environment variable '{}' not found.\n\
42            {}\n\
43            This variable might not be set in your environment.\n\
44            Common path variables: {}",
45            var,
46            os_info,
47            COMMON_PATH_VARIABLES.join(", ")
48        )
49    } else {
50        format!(
51            "Environment variable '{}' not found.\n\
52            {}\n\
53            Did you mean: {}?\n\
54            Common path variables: {}",
55            var,
56            os_info,
57            suggestions.join(", "),
58            COMMON_PATH_VARIABLES.join(", ")
59        )
60    }
61}
62
63/// Handles PATH command operations including add, remove, clean, dedupe, check, list, and move.
64///
65/// # Arguments
66/// * `action` - The specific PATH action to perform, or None to list entries
67/// * `check` - Whether to check for invalid entries when listing
68/// * `var` - The environment variable name (typically "PATH")
69/// * `permanent` - Whether to make changes permanent to the system
70///
71/// # Errors
72/// Returns an error if:
73/// - The specified environment variable is not found
74/// - File system operations fail (creating directories, reading/writing)
75/// - Invalid input is provided for move operations
76/// - Environment variable operations fail
77///
78/// # Panics
79/// Panics if `action` is `None` but the function logic expects it to be `Some`.
80/// This should not happen in normal usage as the logic handles the `None` case before
81/// calling `expect()`.
82#[allow(clippy::too_many_lines)]
83pub fn handle_path_command(action: Option<PathAction>, check: bool, var: &str, permanent: bool) -> Result<()> {
84    let mut manager = EnvVarManager::new();
85    manager.load_all()?;
86
87    // Get the PATH variable
88    let path_var = manager
89        .get(var)
90        .ok_or_else(|| eyre!("{}", get_path_variable_error_message(var, &manager)))?;
91
92    let mut path_mgr = PathManager::new(&path_var.value);
93
94    // If no action specified, list PATH entries and return early
95    if action.is_none() {
96        if check {
97            handle_path_check(&path_mgr, true);
98        }
99        handle_path_list(&path_mgr, false, false);
100        return Ok(());
101    }
102
103    let command = action.expect("We should not reach here if PathAction is None");
104    match command {
105        PathAction::Add {
106            directory,
107            first,
108            create,
109        } => {
110            let path = Path::new(&directory);
111
112            // Check if directory exists
113            if !path.exists() {
114                if create {
115                    std::fs::create_dir_all(path)?;
116                    println!("Created directory: {directory}");
117                } else if !path.exists() {
118                    eprintln!("Warning: Directory does not exist: {directory}");
119                    print!("Add anyway? [y/N]: ");
120                    std::io::stdout().flush()?;
121
122                    let mut input = String::new();
123                    std::io::stdin().read_line(&mut input)?;
124
125                    if !input.trim().eq_ignore_ascii_case("y") {
126                        return Ok(());
127                    }
128                }
129            }
130
131            // Check if already in PATH
132            if path_mgr.contains(&directory) {
133                println!("Directory already in {var}: {directory}");
134                return Ok(());
135            }
136
137            // Add to PATH
138            if first {
139                path_mgr.add_first(directory.clone());
140                println!("Added to beginning of {var}: {directory}");
141            } else {
142                path_mgr.add_last(directory.clone());
143                println!("Added to end of {var}: {directory}");
144            }
145
146            // Save changes
147            let new_value = path_mgr.to_string();
148            manager.set(var, &new_value, permanent)?;
149        }
150
151        PathAction::Remove { directory, all } => {
152            let removed = if all {
153                path_mgr.remove_all(&directory)
154            } else {
155                path_mgr.remove_first(&directory)
156            };
157
158            if removed > 0 {
159                println!("Removed {removed} occurrence(s) of: {directory}");
160                let new_value = path_mgr.to_string();
161                manager.set(var, &new_value, permanent)?;
162            } else {
163                println!("Directory not found in {var}: {directory}");
164            }
165        }
166
167        PathAction::Clean { dedupe, dry_run } => {
168            let invalid = path_mgr.get_invalid();
169            let duplicates = if dedupe { path_mgr.get_duplicates() } else { vec![] };
170
171            if invalid.is_empty() && duplicates.is_empty() {
172                println!("No invalid or duplicate entries found in {var}");
173                return Ok(());
174            }
175
176            if !invalid.is_empty() {
177                println!("Invalid/non-existent paths to remove:");
178                for path in &invalid {
179                    println!("  - {path}");
180                }
181            }
182
183            if !duplicates.is_empty() {
184                println!("Duplicate paths to remove:");
185                for path in &duplicates {
186                    println!("  - {path}");
187                }
188            }
189
190            if dry_run {
191                println!("\n(Dry run - no changes made)");
192            } else {
193                let removed_invalid = path_mgr.remove_invalid();
194                let removed_dupes = if dedupe {
195                    path_mgr.deduplicate(false) // Keep last by default
196                } else {
197                    0
198                };
199
200                println!("Removed {removed_invalid} invalid and {removed_dupes} duplicate entries");
201                let new_value = path_mgr.to_string();
202                manager.set(var, &new_value, permanent)?;
203            }
204        }
205
206        PathAction::Dedupe { keep_first, dry_run } => {
207            let duplicates = path_mgr.get_duplicates();
208
209            if duplicates.is_empty() {
210                println!("No duplicate entries found in {var}");
211                return Ok(());
212            }
213
214            println!("Duplicate paths to remove:");
215            for path in &duplicates {
216                println!("  - {path}");
217            }
218            println!(
219                "Strategy: keep {} occurrence",
220                if keep_first { "first" } else { "last" }
221            );
222
223            if dry_run {
224                println!("\n(Dry run - no changes made)");
225            } else {
226                let removed = path_mgr.deduplicate(keep_first);
227                println!("Removed {removed} duplicate entries");
228                let new_value = path_mgr.to_string();
229                manager.set(var, &new_value, permanent)?;
230            }
231        }
232
233        PathAction::Check { verbose } => {
234            handle_path_check(&path_mgr, verbose);
235        }
236
237        PathAction::List { numbered, check } => {
238            handle_path_list(&path_mgr, numbered, check);
239        }
240
241        PathAction::Move { from, to } => {
242            // Parse from (can be index or path)
243            let from_idx = if let Ok(idx) = from.parse::<usize>() {
244                idx
245            } else {
246                path_mgr
247                    .find_index(&from)
248                    .ok_or_else(|| eyre!("Path not found: {}", from))?
249            };
250
251            // Parse to (can be "first", "last", or index)
252            let to_idx = match to.as_str() {
253                "first" => 0,
254                "last" => path_mgr.len() - 1,
255                _ => to.parse::<usize>().map_err(|_| eyre!("Invalid position: {}", to))?,
256            };
257
258            path_mgr.move_entry(from_idx, to_idx)?;
259            println!("Moved entry from position {from_idx} to {to_idx}");
260
261            let new_value = path_mgr.to_string();
262            manager.set(var, &new_value, permanent)?;
263        }
264    }
265
266    Ok(())
267}
268
269fn handle_path_check(path_mgr: &PathManager, verbose: bool) {
270    let entries = path_mgr.entries();
271    let mut issues = Vec::new();
272    let mut valid_count = 0;
273
274    for (idx, entry) in entries.iter().enumerate() {
275        let path = Path::new(entry);
276        let exists = path.exists();
277        let is_dir = path.is_dir();
278
279        if verbose || !exists {
280            let status = if !exists {
281                issues.push(format!("Not found: {entry}"));
282                "❌ NOT FOUND"
283            } else if !is_dir {
284                issues.push(format!("Not a directory: {entry}"));
285                "⚠️  NOT DIR"
286            } else {
287                valid_count += 1;
288                "✓ OK"
289            };
290
291            if verbose {
292                println!("[{idx:3}] {status} - {entry}");
293            }
294        } else if exists && is_dir {
295            valid_count += 1;
296        }
297    }
298
299    // Summary
300    println!("\nPATH Analysis:");
301    println!("  Total entries: {}", entries.len());
302    println!("  Valid entries: {valid_count}");
303
304    let duplicates = path_mgr.get_duplicates();
305    if !duplicates.is_empty() {
306        println!("  Duplicates: {} entries", duplicates.len());
307        if verbose {
308            for dup in &duplicates {
309                println!("    - {dup}");
310            }
311        }
312    }
313
314    let invalid = path_mgr.get_invalid();
315    if !invalid.is_empty() {
316        println!("  Invalid entries: {}", invalid.len());
317        if verbose {
318            for inv in &invalid {
319                println!("    - {inv}");
320            }
321        }
322    }
323
324    if issues.is_empty() {
325        println!("\n✅ No issues found!");
326    } else {
327        println!("\n⚠️  {} issue(s) found", issues.len());
328        if !verbose {
329            println!("Run with --verbose for details");
330        }
331    }
332}
333
334fn handle_path_list(path_mgr: &PathManager, numbered: bool, check: bool) {
335    let entries = path_mgr.entries();
336
337    if entries.is_empty() {
338        println!("PATH is empty");
339    }
340
341    for (idx, entry) in entries.iter().enumerate() {
342        let prefix = if numbered { format!("[{idx:3}] ") } else { String::new() };
343
344        let suffix = if check {
345            let path = Path::new(entry);
346            if !path.exists() {
347                " [NOT FOUND]"
348            } else if !path.is_dir() {
349                " [NOT A DIRECTORY]"
350            } else {
351                ""
352            }
353        } else {
354            ""
355        };
356
357        println!("{prefix}{entry}{suffix}");
358    }
359}