Skip to main content

atomcode_core/uninstall/
actions.rs

1//! Side-effecting operations: rm, rc-file edits, Windows PATH, self-delete.
2
3/// Remove the canonical `# Added by AtomCode installer\nexport PATH="<prefix>:$PATH"`
4/// block(s) from a shell rc file's content. Strict matching: requires both
5/// the comment and the export line targeting `prefix`. User-written PATH
6/// lines without the comment are left alone.
7///
8/// Returns `Some(new_content)` if at least one block was removed,
9/// `None` otherwise.
10pub fn strip_atomcode_path_block(content: &str, prefix: &str) -> Option<String> {
11    let comment = "# Added by AtomCode installer";
12    let target_export = format!("export PATH=\"{prefix}:$PATH\"");
13
14    let lines: Vec<&str> = content.lines().collect();
15    let mut keep = vec![true; lines.len()];
16    let mut removed_any = false;
17
18    let mut i = 0;
19    while i < lines.len() {
20        if lines[i].trim() == comment {
21            // Look ahead for the export line; allow at most one blank line between.
22            let mut j = i + 1;
23            while j < lines.len() && lines[j].trim().is_empty() {
24                j += 1;
25            }
26            if j < lines.len() && lines[j].trim() == target_export.trim() {
27                // Drop comment + intervening blanks + export line.
28                for flag in keep.iter_mut().take(j + 1).skip(i) {
29                    *flag = false;
30                }
31                // Also drop one trailing blank line if present, to avoid leaving a double blank.
32                if j + 1 < lines.len() && lines[j + 1].trim().is_empty() {
33                    keep[j + 1] = false;
34                }
35                removed_any = true;
36                i = j + 1;
37                continue;
38            }
39        }
40        i += 1;
41    }
42
43    if !removed_any {
44        return None;
45    }
46
47    let mut out = String::with_capacity(content.len());
48    for (idx, line) in lines.iter().enumerate() {
49        if keep[idx] {
50            out.push_str(line);
51            out.push('\n');
52        }
53    }
54    if !content.ends_with('\n') {
55        if let Some(last) = out.strip_suffix('\n') {
56            out = last.to_string();
57        }
58    }
59    Some(out)
60}
61
62/// Remove an entry equal to `target_literal` (e.g. `%LOCALAPPDATA%\AtomCode`)
63/// or `target_expanded` (e.g. `C:\Users\theo\AppData\Local\AtomCode`) from a
64/// Windows PATH-style string. Comparison is case-insensitive and ignores
65/// trailing slashes. Returns `None` if no entry matched.
66pub fn strip_path_entry(path: &str, target_literal: &str, target_expanded: &str) -> Option<String> {
67    let needles = [
68        normalize_path_entry(target_literal),
69        normalize_path_entry(target_expanded),
70    ];
71    let entries: Vec<&str> = path.split(';').collect();
72    let mut kept = Vec::with_capacity(entries.len());
73    let mut removed = false;
74    for e in entries {
75        let n = normalize_path_entry(e);
76        if needles.iter().any(|nd| nd == &n) {
77            removed = true;
78            continue;
79        }
80        kept.push(e);
81    }
82    if !removed {
83        return None;
84    }
85    Some(kept.join(";"))
86}
87
88fn normalize_path_entry(s: &str) -> String {
89    let trimmed = s.trim().trim_end_matches(['\\', '/']);
90    trimmed.to_ascii_lowercase()
91}
92
93#[derive(Debug, Clone)]
94pub struct ProcessInfo {
95    pub pid: u32,
96    pub name: String,
97}
98
99pub fn matches_atomcode_name(name: &str) -> bool {
100    let stripped = name.strip_suffix(".exe").unwrap_or(name);
101    matches!(stripped, "atomcode" | "atomcode-daemon")
102}
103
104/// List all atomcode-family processes excluding the calling process.
105pub fn list_atomcode_processes() -> Vec<ProcessInfo> {
106    use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
107    let mut sys = System::new();
108    sys.refresh_processes_specifics(ProcessesToUpdate::All, true, ProcessRefreshKind::new());
109    let me = sysinfo::get_current_pid().ok();
110    let mut out = Vec::new();
111    for (pid, proc_) in sys.processes() {
112        if Some(*pid) == me {
113            continue;
114        }
115        let name = proc_.name().to_string_lossy();
116        if matches_atomcode_name(&name) {
117            out.push(ProcessInfo {
118                pid: pid.as_u32(),
119                name: name.into_owned(),
120            });
121        }
122    }
123    out
124}
125
126/// Best-effort kill (SIGTERM on Unix, TerminateProcess on Windows) by PID.
127pub fn kill_process(pid: u32) -> std::io::Result<()> {
128    use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
129    let mut sys = System::new();
130    sys.refresh_processes_specifics(ProcessesToUpdate::All, true, ProcessRefreshKind::new());
131    if let Some(p) = sys.process(Pid::from_u32(pid)) {
132        if p.kill() {
133            return Ok(());
134        }
135    }
136    Err(std::io::Error::other(format!("could not kill pid {pid}")))
137}
138
139// ── Filesystem mutators ──────────────────────────────────────────────────────
140
141use std::io;
142use std::path::Path;
143
144/// Remove a file or directory. If `needs_privilege` is true on Unix, shells
145/// out to `sudo rm -rf <path>`; otherwise uses Rust stdlib.
146pub fn remove_path(p: &Path, needs_privilege: bool) -> io::Result<()> {
147    if !p.exists() {
148        return Ok(());
149    }
150    if needs_privilege {
151        return sudo_rm(&[p]);
152    }
153    if p.is_dir() {
154        std::fs::remove_dir_all(p)
155    } else {
156        std::fs::remove_file(p)
157    }
158}
159
160#[cfg(unix)]
161pub fn sudo_rm(paths: &[&Path]) -> io::Result<()> {
162    use std::process::Command;
163    let status = Command::new("sudo")
164        .arg("rm")
165        .arg("-rf")
166        .args(paths)
167        .status()?;
168    if status.success() {
169        Ok(())
170    } else {
171        Err(io::Error::new(
172            io::ErrorKind::PermissionDenied,
173            "sudo rm failed",
174        ))
175    }
176}
177
178#[cfg(not(unix))]
179pub fn sudo_rm(_paths: &[&Path]) -> io::Result<()> {
180    Err(io::Error::new(
181        io::ErrorKind::Other,
182        "sudo not supported on this platform",
183    ))
184}
185
186#[derive(Debug)]
187pub struct PathCleanupResult {
188    pub modified: bool,
189    pub backup_path: Option<std::path::PathBuf>,
190}
191
192/// Read an rc file, strip the AtomCode installer block targeting `prefix`,
193/// write a `.atomcode-uninstall.bak` next to it, then write the cleaned file.
194/// No-op (returns `modified=false`) if the file is missing or no block found.
195pub fn apply_unix_path_cleanup(rc_path: &Path, prefix: &str) -> io::Result<PathCleanupResult> {
196    if !rc_path.exists() {
197        return Ok(PathCleanupResult {
198            modified: false,
199            backup_path: None,
200        });
201    }
202    let content = std::fs::read_to_string(rc_path)?;
203    let new_content = match strip_atomcode_path_block(&content, prefix) {
204        Some(c) => c,
205        None => {
206            return Ok(PathCleanupResult {
207                modified: false,
208                backup_path: None,
209            })
210        }
211    };
212    let backup = {
213        let mut s = rc_path.as_os_str().to_os_string();
214        s.push(".atomcode-uninstall.bak");
215        std::path::PathBuf::from(s)
216    };
217    std::fs::copy(rc_path, &backup)?;
218    std::fs::write(rc_path, new_content)?;
219    Ok(PathCleanupResult {
220        modified: true,
221        backup_path: Some(backup),
222    })
223}
224
225#[cfg(windows)]
226pub fn apply_windows_path_cleanup(
227    install_dir_literal: &str,
228    install_dir_expanded: &str,
229) -> io::Result<bool> {
230    use winreg::enums::*;
231    use winreg::RegKey;
232
233    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
234    let env = hkcu
235        .open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)
236        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("open Environment key: {e}")))?;
237    let cur: String = env.get_value("Path").unwrap_or_default();
238    let new = match strip_path_entry(&cur, install_dir_literal, install_dir_expanded) {
239        Some(s) => s,
240        None => return Ok(false),
241    };
242    env.set_value("Path", &new)
243        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("write Path: {e}")))?;
244    broadcast_setting_change();
245    Ok(true)
246}
247
248#[cfg(windows)]
249fn broadcast_setting_change() {
250    use std::ffi::CString;
251    use windows_sys::Win32::UI::WindowsAndMessaging::{
252        SendMessageTimeoutA, HWND_BROADCAST, SMTO_ABORTIFHUNG, WM_SETTINGCHANGE,
253    };
254    let env = CString::new("Environment").unwrap();
255    unsafe {
256        let mut result: usize = 0;
257        SendMessageTimeoutA(
258            HWND_BROADCAST,
259            WM_SETTINGCHANGE,
260            0,
261            env.as_ptr() as isize,
262            SMTO_ABORTIFHUNG,
263            5000,
264            &mut result,
265        );
266    }
267}
268
269// ── Self-delete strategy ─────────────────────────────────────────────────────
270
271/// Strategy abstraction so tests can override the actual self-delete step.
272pub trait SelfDeleteStrategy {
273    fn run(&self, exe: &Path) -> io::Result<()>;
274}
275
276pub struct PlatformSelfDelete;
277
278impl SelfDeleteStrategy for PlatformSelfDelete {
279    #[cfg(unix)]
280    fn run(&self, exe: &Path) -> io::Result<()> {
281        // POSIX: we can unlink ourselves; the inode lives until exit.
282        if let Some(parent) = exe.parent() {
283            // If parent is not effectively writable, sudo it.
284            use std::os::unix::ffi::OsStrExt;
285            let parent_c = std::ffi::CString::new(parent.as_os_str().as_bytes())
286                .unwrap_or_else(|_| std::ffi::CString::new(".").unwrap());
287            let writable = unsafe { libc::access(parent_c.as_ptr(), libc::W_OK) == 0 };
288            if !writable {
289                return sudo_rm(&[exe]);
290            }
291        }
292        std::fs::remove_file(exe)
293    }
294
295    #[cfg(windows)]
296    fn run(&self, exe: &Path) -> io::Result<()> {
297        use std::os::windows::process::CommandExt;
298        use std::process::Command;
299        const CREATE_NO_WINDOW: u32 = 0x08000000;
300        const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
301
302        // Rename live exe to .atomcode.rolling so the install dir can be deleted.
303        let rolling = crate::self_update::rolling_path(exe);
304        if exe.file_name() != rolling.file_name() {
305            let _ = std::fs::rename(exe, &rolling);
306        }
307        let install_dir = exe
308            .parent()
309            .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "no parent dir"))?;
310        let dir_str = install_dir.to_string_lossy().to_string();
311
312        // Use `timeout` for the delay instead of `ping` — it is semantically
313        // clearer and avoids the "cmd window flashing ping 127.0.0.1" bug
314        // reported in gitcode.com/atomgit_atomcode/atomcode/issues/352.
315        // CREATE_NO_WINDOW prevents the console window from appearing at all
316        // (DETACHED_PROCESS does NOT reliably hide the window on Win10).
317        let cmd_arg = format!(
318            "timeout /t 2 /nobreak >nul & rmdir /S /Q \"{}\"",
319            dir_str
320        );
321        Command::new("cmd")
322            .args(["/C", &cmd_arg])
323            .creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP)
324            .spawn()?;
325        Ok(())
326    }
327
328    #[cfg(not(any(unix, windows)))]
329    fn run(&self, exe: &Path) -> io::Result<()> {
330        std::fs::remove_file(exe)
331    }
332}
333
334/// Test stub strategy used by integration tests to avoid actually self-deleting.
335pub struct NoopSelfDelete;
336
337impl SelfDeleteStrategy for NoopSelfDelete {
338    fn run(&self, _exe: &Path) -> io::Result<()> {
339        Ok(())
340    }
341}
342
343#[cfg(test)]
344mod path_line_tests {
345    use super::strip_atomcode_path_block;
346
347    const PREFIX: &str = "/Users/test/.local/bin";
348
349    #[test]
350    fn strips_canonical_block() {
351        let input = "\
352# user stuff
353alias gs=\"git status\"
354
355# Added by AtomCode installer
356export PATH=\"/Users/test/.local/bin:$PATH\"
357
358# more user stuff
359";
360        let expect = "\
361# user stuff
362alias gs=\"git status\"
363
364# more user stuff
365";
366        assert_eq!(
367            strip_atomcode_path_block(input, PREFIX).as_deref(),
368            Some(expect)
369        );
370    }
371
372    #[test]
373    fn returns_none_when_no_block() {
374        let input = "alias gs=\"git status\"\n";
375        assert_eq!(strip_atomcode_path_block(input, PREFIX), None);
376    }
377
378    #[test]
379    fn strips_multiple_blocks_from_repeat_installs() {
380        let input = "\
381# Added by AtomCode installer
382export PATH=\"/Users/test/.local/bin:$PATH\"
383
384alias x=1
385
386# Added by AtomCode installer
387export PATH=\"/Users/test/.local/bin:$PATH\"
388";
389        let out = strip_atomcode_path_block(input, PREFIX).unwrap();
390        assert!(!out.contains("AtomCode installer"));
391        assert!(out.contains("alias x=1"));
392    }
393
394    #[test]
395    fn does_not_touch_user_written_path_lines() {
396        let input = "\
397export PATH=\"/Users/test/.local/bin:$PATH\"
398# unrelated comment
399";
400        // No installer comment → must return None even though prefix matches.
401        assert_eq!(strip_atomcode_path_block(input, PREFIX), None);
402    }
403
404    #[test]
405    fn ignores_block_with_different_prefix() {
406        let input = "\
407# Added by AtomCode installer
408export PATH=\"/opt/somewhere/else:$PATH\"
409";
410        assert_eq!(strip_atomcode_path_block(input, PREFIX), None);
411    }
412
413    #[test]
414    fn handles_block_at_end_of_file() {
415        let input = "alias x=1\n\n# Added by AtomCode installer\nexport PATH=\"/Users/test/.local/bin:$PATH\"\n";
416        let out = strip_atomcode_path_block(input, PREFIX).unwrap();
417        assert_eq!(out.trim_end(), "alias x=1");
418    }
419}
420
421#[cfg(test)]
422mod windows_path_tests {
423    use super::strip_path_entry;
424
425    #[test]
426    fn strips_exact_match() {
427        let path = r"C:\Program Files\Git\cmd;C:\Users\theo\AppData\Local\AtomCode;C:\Windows";
428        let target = r"C:\Users\theo\AppData\Local\AtomCode";
429        let expanded = r"C:\Users\theo\AppData\Local\AtomCode";
430        let out = strip_path_entry(path, target, expanded);
431        assert_eq!(
432            out,
433            Some(r"C:\Program Files\Git\cmd;C:\Windows".to_string())
434        );
435    }
436
437    #[test]
438    fn case_insensitive() {
439        let path = r"c:\users\Theo\appdata\local\atomcode;C:\Windows";
440        let out = strip_path_entry(
441            path,
442            r"C:\Users\theo\AppData\Local\AtomCode",
443            r"C:\Users\theo\AppData\Local\AtomCode",
444        );
445        assert_eq!(out, Some(r"C:\Windows".to_string()));
446    }
447
448    #[test]
449    fn ignores_trailing_backslash() {
450        let path = r"C:\Users\theo\AppData\Local\AtomCode\;C:\Windows";
451        let out = strip_path_entry(
452            path,
453            r"C:\Users\theo\AppData\Local\AtomCode",
454            r"C:\Users\theo\AppData\Local\AtomCode",
455        );
456        assert_eq!(out, Some(r"C:\Windows".to_string()));
457    }
458
459    #[test]
460    fn matches_unexpanded_localappdata() {
461        let path = r"%LOCALAPPDATA%\AtomCode;C:\Windows";
462        let out = strip_path_entry(
463            path,
464            r"%LOCALAPPDATA%\AtomCode",
465            r"C:\Users\theo\AppData\Local\AtomCode",
466        );
467        assert!(out.unwrap().eq_ignore_ascii_case(r"C:\Windows"));
468    }
469
470    #[test]
471    fn returns_none_when_not_present() {
472        let path = r"C:\Windows;C:\Program Files\Git\cmd";
473        let out = strip_path_entry(path, r"C:\nope", r"C:\nope");
474        assert_eq!(out, None);
475    }
476
477    #[test]
478    fn preserves_other_atomcode_substring_entries() {
479        // A directory that *contains* AtomCode in its name but isn't the install dir.
480        let path = r"C:\AtomCodeStuff\bin;C:\Users\theo\AppData\Local\AtomCode;C:\Windows";
481        let out = strip_path_entry(
482            path,
483            r"C:\Users\theo\AppData\Local\AtomCode",
484            r"C:\Users\theo\AppData\Local\AtomCode",
485        );
486        assert_eq!(out, Some(r"C:\AtomCodeStuff\bin;C:\Windows".to_string()));
487    }
488}
489
490#[cfg(test)]
491mod process_tests {
492    use super::*;
493
494    #[test]
495    fn excludes_self() {
496        let me = std::process::id();
497        let procs = list_atomcode_processes();
498        for p in procs {
499            assert_ne!(p.pid, me);
500        }
501    }
502
503    #[test]
504    fn name_matcher_recognizes_atomcode_variants() {
505        assert!(matches_atomcode_name("atomcode"));
506        assert!(matches_atomcode_name("atomcode.exe"));
507        assert!(matches_atomcode_name("atomcode-daemon"));
508        assert!(matches_atomcode_name("atomcode-daemon.exe"));
509        assert!(!matches_atomcode_name("vscode"));
510        assert!(!matches_atomcode_name("atomcode-stuff"));
511    }
512}
513
514#[cfg(test)]
515mod remove_tests {
516    use super::*;
517    use tempfile::TempDir;
518
519    #[test]
520    fn removes_file() {
521        let tmp = TempDir::new().unwrap();
522        let p = tmp.path().join("x");
523        std::fs::write(&p, b"hi").unwrap();
524        remove_path(&p, false).unwrap();
525        assert!(!p.exists());
526    }
527
528    #[test]
529    fn removes_dir_recursively() {
530        let tmp = TempDir::new().unwrap();
531        let d = tmp.path().join("d");
532        std::fs::create_dir(&d).unwrap();
533        std::fs::write(d.join("a"), b"a").unwrap();
534        remove_path(&d, false).unwrap();
535        assert!(!d.exists());
536    }
537
538    #[test]
539    fn nonexistent_path_is_ok() {
540        let tmp = TempDir::new().unwrap();
541        remove_path(&tmp.path().join("missing"), false).unwrap();
542    }
543}
544
545#[cfg(test)]
546mod rc_apply_tests {
547    use super::*;
548    use tempfile::TempDir;
549
550    #[test]
551    fn backup_created_and_block_removed() {
552        let tmp = TempDir::new().unwrap();
553        let rc = tmp.path().join(".zshrc");
554        std::fs::write(
555            &rc,
556            "# Added by AtomCode installer\nexport PATH=\"/p:$PATH\"\n",
557        )
558        .unwrap();
559        let res = apply_unix_path_cleanup(&rc, "/p").unwrap();
560        assert!(res.modified);
561        assert!(rc.with_file_name(".zshrc.atomcode-uninstall.bak").exists());
562        let new = std::fs::read_to_string(&rc).unwrap();
563        assert!(!new.contains("AtomCode"));
564    }
565
566    #[test]
567    fn no_change_when_block_absent() {
568        let tmp = TempDir::new().unwrap();
569        let rc = tmp.path().join(".zshrc");
570        std::fs::write(&rc, "alias x=1\n").unwrap();
571        let res = apply_unix_path_cleanup(&rc, "/p").unwrap();
572        assert!(!res.modified);
573        assert!(!rc.with_file_name(".zshrc.atomcode-uninstall.bak").exists());
574    }
575}
576
577#[cfg(test)]
578mod execute_tests {
579    use super::super::{execute, scan, Decisions};
580    use super::NoopSelfDelete;
581    use tempfile::TempDir;
582
583    fn fake_install(tmp: &TempDir) -> (std::path::PathBuf, std::path::PathBuf) {
584        let exe = tmp.path().join("atomcode");
585        std::fs::write(&exe, b"x").unwrap();
586        let data = tmp.path().join(".atomcode");
587        std::fs::create_dir(&data).unwrap();
588        std::fs::write(data.join("auth.toml"), b"k").unwrap();
589        std::fs::write(data.join("history"), b"h").unwrap();
590        std::fs::create_dir(data.join("plugins")).unwrap();
591        (exe, data)
592    }
593
594    #[test]
595    fn keep_data_only_removes_binary() {
596        let tmp = TempDir::new().unwrap();
597        let (exe, data) = fake_install(&tmp);
598        let plan = scan::scan(&exe, &data).unwrap();
599        let outcome = execute(&plan, Decisions::KEEP_DATA, &NoopSelfDelete, None).unwrap();
600        // NoopSelfDelete doesn't actually delete the file, but execute() records it.
601        // Our assertions: data files preserved.
602        assert!(data.join("auth.toml").exists());
603        assert!(data.join("history").exists());
604        assert!(outcome.failed.is_empty());
605    }
606
607    #[test]
608    fn purge_removes_everything_under_data() {
609        let tmp = TempDir::new().unwrap();
610        let (exe, data) = fake_install(&tmp);
611        let plan = scan::scan(&exe, &data).unwrap();
612        execute(&plan, Decisions::PURGE, &NoopSelfDelete, None).unwrap();
613        assert!(!data.join("auth.toml").exists());
614        assert!(!data.join("history").exists());
615        assert!(!data.join("plugins").exists());
616    }
617
618    #[test]
619    fn defaults_keep_credentials_remove_state() {
620        let tmp = TempDir::new().unwrap();
621        let (exe, data) = fake_install(&tmp);
622        let plan = scan::scan(&exe, &data).unwrap();
623        execute(&plan, Decisions::DEFAULTS, &NoopSelfDelete, None).unwrap();
624        assert!(data.join("auth.toml").exists()); // kept
625        assert!(!data.join("history").exists());
626        assert!(!data.join("plugins").exists());
627    }
628
629    #[test]
630    fn execution_order_state_then_credentials_then_binary() {
631        let tmp = TempDir::new().unwrap();
632        let (exe, data) = fake_install(&tmp);
633        let plan = scan::scan(&exe, &data).unwrap();
634        let outcome = execute(&plan, Decisions::PURGE, &NoopSelfDelete, None).unwrap();
635        // Removed list ordering proves the spec-mandated order.
636        // history (state) must appear before auth.toml (credentials), which
637        // must appear before the binary path itself.
638        let pos_history = outcome
639            .removed
640            .iter()
641            .position(|p| p.file_name().and_then(|n| n.to_str()) == Some("history"))
642            .expect("history was not removed");
643        let pos_auth = outcome
644            .removed
645            .iter()
646            .position(|p| p.file_name().and_then(|n| n.to_str()) == Some("auth.toml"))
647            .expect("auth.toml was not removed");
648        let pos_bin = outcome
649            .removed
650            .iter()
651            .position(|p| p == &exe)
652            .expect("binary was not removed");
653        assert!(
654            pos_history < pos_auth,
655            "state should be removed before credentials"
656        );
657        assert!(
658            pos_auth < pos_bin,
659            "credentials should be removed before binary"
660        );
661    }
662}