1pub 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 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 for flag in keep.iter_mut().take(j + 1).skip(i) {
29 *flag = false;
30 }
31 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
62pub 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
104pub 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
126pub 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
139use std::io;
142use std::path::Path;
143
144pub 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
192pub 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
269pub 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 if let Some(parent) = exe.parent() {
283 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 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 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
334pub 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 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 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 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()); 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 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}