Skip to main content

upstream_rs/services/integration/
shell_manager.rs

1#[cfg(unix)]
2use crate::utils::filesystem::atomic_ops::write_atomic;
3use anyhow::{Context, Result};
4#[cfg(unix)]
5use std::fs;
6use std::path::Path;
7#[cfg(unix)]
8use std::path::PathBuf;
9#[cfg(unix)]
10use std::sync::{Mutex, OnceLock};
11
12/// Process-global lock used to serialize reads/writes to the shared PATH file.
13#[cfg(unix)]
14fn paths_file_lock() -> &'static Mutex<()> {
15    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
16    LOCK.get_or_init(|| Mutex::new(()))
17}
18
19#[cfg(windows)]
20fn normalize_windows_path(path: &str) -> String {
21    let mut normalized = path.replace('/', "\\").trim().to_ascii_lowercase();
22    while normalized.ends_with('\\') {
23        normalized.pop();
24    }
25    normalized
26}
27
28pub struct ShellManager<'a> {
29    paths_file: &'a Path,
30    #[cfg(unix)]
31    paths_nu_file: PathBuf,
32}
33
34impl<'a> ShellManager<'a> {
35    pub fn new(paths_file: &'a Path) -> Self {
36        Self {
37            paths_file,
38            #[cfg(unix)]
39            paths_nu_file: paths_file.with_extension("nu"),
40        }
41    }
42
43    /// Adds a package's installation path to PATH
44    pub fn add_to_paths(&self, install_path: &Path) -> Result<()> {
45        if !install_path.is_dir() {
46            anyhow::bail!(
47                "Package install directory not found: {}",
48                install_path.to_string_lossy()
49            );
50        }
51
52        #[cfg(unix)]
53        {
54            let _guard = paths_file_lock()
55                .lock()
56                .ok()
57                .ok_or_else(|| anyhow::anyhow!("Failed to lock PATH file for writing"))?;
58            let mut content =
59                fs::read_to_string(self.paths_file).context("Failed to read paths file")?;
60            let escaped = install_path
61                .to_string_lossy()
62                .replace('$', "\\$")
63                .replace('"', "\\\"");
64            let export_line = format!("export PATH=\"{escaped}:$PATH\"");
65
66            if !content.contains(&export_line) {
67                content.push_str(&format!("{export_line}\n"));
68                write_atomic(self.paths_file, content.as_bytes())
69                    .context("Failed to write paths file")?;
70            }
71
72            let nushell_content = fs::read_to_string(&self.paths_nu_file).unwrap_or_default();
73            let mut nushell_paths = parse_nushell_paths_file(&nushell_content);
74            let install_path = install_path.to_string_lossy().to_string();
75
76            if !nushell_paths.contains(&install_path) {
77                nushell_paths.insert(0, install_path);
78                let rendered = render_nushell_paths_file(&nushell_paths);
79                write_atomic(&self.paths_nu_file, rendered.as_bytes())
80                    .context("Failed to write Nushell paths file")?;
81            }
82        }
83
84        #[cfg(windows)]
85        {
86            let _ = self.paths_file;
87            self.add_to_windows_registry(install_path)?;
88        }
89
90        Ok(())
91    }
92
93    /// Removes a package's PATH entry
94    pub fn remove_from_paths(&self, install_path: &Path) -> Result<()> {
95        #[cfg(unix)]
96        {
97            let _guard = paths_file_lock()
98                .lock()
99                .ok()
100                .ok_or_else(|| anyhow::anyhow!("Failed to lock PATH file for writing"))?;
101            let mut content =
102                fs::read_to_string(self.paths_file).context("Failed to read paths file")?;
103            let escaped = install_path
104                .to_string_lossy()
105                .replace('$', "\\$")
106                .replace('"', "\\\"");
107            let export_line = format!("export PATH=\"{escaped}:$PATH\"");
108
109            content = content.replace(&format!("{export_line}\n"), "");
110            content = content.replace(&export_line, "");
111            write_atomic(self.paths_file, content.as_bytes())
112                .context("Failed to write paths file")?;
113
114            if self.paths_nu_file.exists() {
115                let nushell_content = fs::read_to_string(&self.paths_nu_file)
116                    .context("Failed to read Nushell paths file")?;
117                let target = install_path.to_string_lossy().to_string();
118                let mut nushell_paths = parse_nushell_paths_file(&nushell_content);
119                let original_len = nushell_paths.len();
120                nushell_paths.retain(|path| path != &target);
121
122                if nushell_paths.len() != original_len {
123                    let rendered = render_nushell_paths_file(&nushell_paths);
124                    write_atomic(&self.paths_nu_file, rendered.as_bytes())
125                        .context("Failed to write Nushell paths file")?;
126                }
127            }
128        }
129
130        #[cfg(windows)]
131        {
132            let _ = self.paths_file;
133            self.remove_from_windows_registry(install_path)?;
134        }
135
136        Ok(())
137    }
138
139    #[cfg(windows)]
140    fn add_to_windows_registry(&self, path: &Path) -> Result<()> {
141        use winreg::RegKey;
142        use winreg::enums::*;
143
144        let hkcu = RegKey::predef(HKEY_CURRENT_USER);
145        let env_key = hkcu
146            .open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)
147            .context("Failed to open registry Environment key")?;
148
149        let target_path = path.to_string_lossy().to_string();
150        let target_norm = normalize_windows_path(&target_path);
151
152        // Get current PATH
153        let current_path: String = env_key.get_value("Path").unwrap_or_else(|_| String::new());
154
155        // Check if path is already in PATH
156        let path_entries: Vec<&str> = current_path.split(';').collect();
157        if path_entries
158            .iter()
159            .any(|&p| normalize_windows_path(p) == target_norm)
160        {
161            return Ok(()); // Already in PATH
162        }
163
164        // Add path to the beginning
165        let new_path = if current_path.is_empty() {
166            target_path
167        } else {
168            format!("{};{}", target_path, current_path)
169        };
170
171        env_key
172            .set_value("Path", &new_path)
173            .context("Failed to set PATH in registry")?;
174
175        // Broadcast environment change
176        Self::broadcast_environment_change();
177
178        Ok(())
179    }
180
181    #[cfg(windows)]
182    fn remove_from_windows_registry(&self, path: &Path) -> Result<()> {
183        use winreg::RegKey;
184        use winreg::enums::*;
185
186        let hkcu = RegKey::predef(HKEY_CURRENT_USER);
187        let env_key = hkcu
188            .open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)
189            .context("Failed to open registry Environment key")?;
190
191        let target_path = path.to_string_lossy().to_string();
192        let target_norm = normalize_windows_path(&target_path);
193
194        // Get current PATH
195        let current_path: String = env_key.get_value("Path").unwrap_or_else(|_| String::new());
196
197        // Remove target path from PATH
198        let path_entries: Vec<&str> = current_path
199            .split(';')
200            .filter(|&p| normalize_windows_path(p) != target_norm)
201            .collect();
202
203        let new_path = path_entries.join(";");
204
205        env_key
206            .set_value("Path", &new_path)
207            .context("Failed to set PATH in registry")?;
208
209        // Broadcast environment change
210        Self::broadcast_environment_change();
211
212        Ok(())
213    }
214
215    #[cfg(windows)]
216    fn broadcast_environment_change() {
217        use std::ptr;
218        use winapi::shared::minwindef::LPARAM;
219        use winapi::um::winuser::{
220            HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
221        };
222
223        unsafe {
224            let env_string: Vec<u16> = "Environment\0".encode_utf16().collect();
225            SendMessageTimeoutW(
226                HWND_BROADCAST,
227                WM_SETTINGCHANGE,
228                0,
229                env_string.as_ptr() as LPARAM,
230                SMTO_ABORTIFHUNG,
231                5000,
232                ptr::null_mut(),
233            );
234        }
235    }
236}
237
238#[cfg(unix)]
239fn escape_nushell_string(value: &str) -> String {
240    value.replace('\\', "\\\\").replace('"', "\\\"")
241}
242
243#[cfg(unix)]
244pub fn render_nushell_paths_file(paths: &[String]) -> String {
245    let mut content = String::from("# Upstream managed PATH additions\n\nlet upstream_paths = [\n");
246    for path in paths {
247        content.push_str(&format!("    \"{}\"\n", escape_nushell_string(path)));
248    }
249    content.push_str("]\n\n$env.PATH = ($upstream_paths ++ $env.PATH)\n");
250    content
251}
252
253#[cfg(unix)]
254pub fn nushell_paths_file_contains_path(content: &str, path: &str) -> bool {
255    parse_nushell_paths_file(content)
256        .iter()
257        .any(|entry| entry == path)
258}
259
260#[cfg(unix)]
261fn parse_nushell_paths_file(content: &str) -> Vec<String> {
262    let mut list_entries = Vec::new();
263    let mut prepend_entries = Vec::new();
264    let mut in_list = false;
265
266    for line in content.lines() {
267        let trimmed = line.trim();
268        if trimmed == "let upstream_paths = [" {
269            in_list = true;
270            continue;
271        }
272        if in_list && trimmed == "]" {
273            in_list = false;
274            continue;
275        }
276
277        if in_list {
278            if let Some((path, _)) = parse_nushell_string_literal(trimmed) {
279                list_entries.push(path);
280            }
281            continue;
282        }
283
284        if let Some(prepend_index) = trimmed.find("| prepend ") {
285            let rest = trimmed[prepend_index + "| prepend ".len()..].trim_start();
286            if let Some((path, _)) = parse_nushell_string_literal(rest) {
287                prepend_entries.push(path);
288            }
289        }
290    }
291
292    if list_entries.is_empty() {
293        prepend_entries.reverse();
294        dedupe_preserving_order(prepend_entries)
295    } else {
296        prepend_entries.reverse();
297        list_entries.extend(prepend_entries);
298        dedupe_preserving_order(list_entries)
299    }
300}
301
302#[cfg(unix)]
303fn parse_nushell_string_literal(input: &str) -> Option<(String, usize)> {
304    let mut chars = input.char_indices();
305    let (_, first) = chars.next()?;
306    if first != '"' {
307        return None;
308    }
309
310    let mut output = String::new();
311    let mut escaped = false;
312    for (index, ch) in chars {
313        if escaped {
314            match ch {
315                '"' | '\\' => output.push(ch),
316                other => {
317                    output.push('\\');
318                    output.push(other);
319                }
320            }
321            escaped = false;
322            continue;
323        }
324
325        match ch {
326            '\\' => escaped = true,
327            '"' => return Some((output, index + ch.len_utf8())),
328            other => output.push(other),
329        }
330    }
331
332    None
333}
334
335#[cfg(unix)]
336fn dedupe_preserving_order(paths: Vec<String>) -> Vec<String> {
337    let mut unique = Vec::new();
338    for path in paths {
339        if !unique.contains(&path) {
340            unique.push(path);
341        }
342    }
343    unique
344}
345
346#[cfg(test)]
347mod tests {
348    #[cfg(unix)]
349    use super::{ShellManager, parse_nushell_paths_file};
350    #[cfg(unix)]
351    use std::path::{Path, PathBuf};
352    #[cfg(unix)]
353    use std::time::{SystemTime, UNIX_EPOCH};
354    #[cfg(unix)]
355    use std::{fs, io};
356
357    #[cfg(unix)]
358    fn temp_root(name: &str) -> PathBuf {
359        let nanos = SystemTime::now()
360            .duration_since(UNIX_EPOCH)
361            .map(|d| d.as_nanos())
362            .unwrap_or(0);
363        std::env::temp_dir().join(format!("upstream-shell-test-{name}-{nanos}"))
364    }
365
366    #[cfg(unix)]
367    fn cleanup(path: &Path) -> io::Result<()> {
368        fs::remove_dir_all(path)
369    }
370
371    #[cfg(unix)]
372    #[test]
373    fn add_to_paths_is_idempotent_and_escapes_special_characters() {
374        let root = temp_root("add-idempotent");
375        let install_path = root.join("tool\"dir$");
376        let paths_file = root.join("paths.sh");
377        let paths_nu_file = root.join("paths.nu");
378        fs::create_dir_all(&install_path).expect("create install dir");
379        fs::write(&paths_file, "#!/usr/bin/env sh\n").expect("create paths file");
380        fs::write(&paths_nu_file, "# Upstream managed PATH additions\n").expect("create paths.nu");
381        let manager = ShellManager::new(&paths_file);
382
383        manager.add_to_paths(&install_path).expect("first add");
384        manager.add_to_paths(&install_path).expect("second add");
385
386        let content = fs::read_to_string(&paths_file).expect("read paths file");
387        assert_eq!(content.matches("export PATH=").count(), 1);
388        assert!(content.contains("\\\""));
389        assert!(content.contains("\\$"));
390
391        let nushell_content = fs::read_to_string(&paths_nu_file).expect("read paths.nu");
392        assert!(nushell_content.contains("let upstream_paths = ["));
393        assert!(nushell_content.contains("$env.PATH = ($upstream_paths ++ $env.PATH)"));
394        assert_eq!(
395            parse_nushell_paths_file(&nushell_content),
396            vec![install_path.to_string_lossy().to_string()]
397        );
398        assert!(nushell_content.contains("\\\""));
399        assert!(nushell_content.contains("$"));
400
401        cleanup(&root).expect("cleanup");
402    }
403
404    #[cfg(unix)]
405    #[test]
406    fn remove_from_paths_removes_existing_export_line() {
407        let root = temp_root("remove");
408        let install_path = root.join("pkg/bin");
409        let paths_file = root.join("paths.sh");
410        let paths_nu_file = root.join("paths.nu");
411        fs::create_dir_all(&install_path).expect("create install dir");
412        fs::write(&paths_file, "").expect("create paths file");
413        fs::write(&paths_nu_file, "# Upstream managed PATH additions\n").expect("create paths.nu");
414        let manager = ShellManager::new(&paths_file);
415
416        manager.add_to_paths(&install_path).expect("add path");
417        manager
418            .remove_from_paths(&install_path)
419            .expect("remove path");
420
421        let content = fs::read_to_string(&paths_file).expect("read paths file");
422        assert!(!content.contains("export PATH="));
423
424        let nushell_content = fs::read_to_string(&paths_nu_file).expect("read paths.nu");
425        assert!(parse_nushell_paths_file(&nushell_content).is_empty());
426
427        cleanup(&root).expect("cleanup");
428    }
429
430    #[cfg(unix)]
431    #[test]
432    fn add_to_paths_migrates_old_nushell_prepend_lines_to_managed_list() {
433        let root = temp_root("migrate-nu");
434        let first_path = root.join("first/bin");
435        let second_path = root.join("second/bin");
436        let new_path = root.join("new/bin");
437        let paths_file = root.join("paths.sh");
438        let paths_nu_file = root.join("paths.nu");
439        fs::create_dir_all(&first_path).expect("create first dir");
440        fs::create_dir_all(&second_path).expect("create second dir");
441        fs::create_dir_all(&new_path).expect("create new dir");
442        fs::write(&paths_file, "#!/usr/bin/env sh\n").expect("create paths file");
443        fs::write(
444            &paths_nu_file,
445            format!(
446                "# Upstream managed PATH additions\n\
447                 $env.PATH = ($env.PATH | prepend \"{}\")\n\
448                 $env.PATH = ($env.PATH | prepend \"{}\")\n",
449                first_path.display(),
450                second_path.display()
451            ),
452        )
453        .expect("create old paths.nu");
454        let manager = ShellManager::new(&paths_file);
455
456        manager.add_to_paths(&new_path).expect("add path");
457
458        let nushell_content = fs::read_to_string(&paths_nu_file).expect("read paths.nu");
459        assert!(!nushell_content.contains(" | prepend "));
460        assert_eq!(
461            parse_nushell_paths_file(&nushell_content),
462            vec![
463                new_path.to_string_lossy().to_string(),
464                second_path.to_string_lossy().to_string(),
465                first_path.to_string_lossy().to_string(),
466            ]
467        );
468
469        cleanup(&root).expect("cleanup");
470    }
471}