1use crate::constants::env::system;
4use std::path::Path;
5use std::process::Command;
6use std::sync::Mutex;
7use std::time::SystemTime;
8
9use serde::{Deserialize, Serialize};
10
11use crate::utils::platform::detect_platform as get_platform;
12
13lazy_static::lazy_static! {
14 static ref OS_RELEASE_CACHE: Mutex<Option<OsRelease>> = Mutex::new(None);
15 static ref PACKAGE_MANAGER_CACHE: Mutex<Option<PackageManager>> = Mutex::new(None);
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct VersionLockContent {
20 pub pid: u32,
21 pub version: String,
22 pub exec_path: String,
23 pub acquired_at: u64,
24}
25
26#[derive(Debug, Clone)]
27pub struct LockInfo {
28 pub version: String,
29 pub pid: u32,
30 pub is_process_running: bool,
31 pub exec_path: String,
32 pub acquired_at: SystemTime,
33 pub lock_file_path: String,
34}
35
36#[derive(Debug, Clone)]
37pub struct OsRelease {
38 pub id: String,
39 pub id_like: Vec<String>,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum PackageManager {
44 Homebrew,
45 Winget,
46 Pacman,
47 Deb,
48 Rpm,
49 Apk,
50 Mise,
51 Asdf,
52 Unknown,
53}
54
55impl PackageManager {
56 pub fn as_str(&self) -> &'static str {
57 match self {
58 PackageManager::Homebrew => "homebrew",
59 PackageManager::Winget => "winget",
60 PackageManager::Pacman => "pacman",
61 PackageManager::Deb => "deb",
62 PackageManager::Rpm => "rpm",
63 PackageManager::Apk => "apk",
64 PackageManager::Mise => "mise",
65 PackageManager::Asdf => "asdf",
66 PackageManager::Unknown => "unknown",
67 }
68 }
69}
70
71const FALLBACK_STALE_MS: u64 = 2 * 60 * 60 * 1000;
72
73pub fn is_pid_based_locking_enabled() -> bool {
74 if let Ok(val) = std::env::var(system::ENABLE_PID_BASED_VERSION_LOCKING) {
75 let val_lower = val.to_lowercase();
76 if val_lower == "true" || val_lower == "1" || val_lower == "yes" {
77 return true;
78 }
79 if val_lower == "false" || val_lower == "0" || val_lower == "no" || val_lower.is_empty() {
80 return false;
81 }
82 }
83 false
84}
85
86#[cfg(unix)]
87pub fn is_process_running(pid: u32) -> bool {
88 if pid <= 1 {
89 return false;
90 }
91 unsafe {
92 let result = libc::kill(pid as libc::pid_t, 0);
93 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM as i32)
94 }
95}
96
97#[cfg(not(unix))]
98pub fn is_process_running(pid: u32) -> bool {
99 if pid <= 1 {
100 return false;
101 }
102 pid == std::process::id()
103}
104
105fn is_claude_process(pid: u32, expected_exec_path: &str) -> bool {
106 if !is_process_running(pid) {
107 return false;
108 }
109 if pid == std::process::id() {
110 return true;
111 }
112 true
113}
114
115pub fn read_lock_content(lock_file_path: &str) -> Option<VersionLockContent> {
116 let path = Path::new(lock_file_path);
117 match std::fs::read_to_string(path) {
118 Ok(content) => {
119 if content.trim().is_empty() {
120 return None;
121 }
122 match serde_json::from_str::<VersionLockContent>(&content) {
123 Ok(parsed) => {
124 if parsed.version.is_empty() || parsed.exec_path.is_empty() {
125 None
126 } else {
127 Some(parsed)
128 }
129 }
130 Err(_) => None,
131 }
132 }
133 Err(_) => None,
134 }
135}
136
137pub fn is_lock_active(lock_file_path: &str) -> bool {
138 let content = match read_lock_content(lock_file_path) {
139 Some(c) => c,
140 None => return false,
141 };
142
143 let pid = content.pid;
144
145 if !is_process_running(pid) {
146 return false;
147 }
148
149 if !is_claude_process(pid, &content.exec_path) {
150 return false;
151 }
152
153 let path = Path::new(lock_file_path);
154 if let Ok(metadata) = std::fs::metadata(path) {
155 if let Ok(modified) = metadata.modified() {
156 let age = SystemTime::now()
157 .duration_since(modified)
158 .unwrap_or(std::time::Duration::ZERO)
159 .as_millis() as u64;
160
161 if age > FALLBACK_STALE_MS {
162 if !is_process_running(pid) {
163 return false;
164 }
165 }
166 }
167 }
168
169 true
170}
171
172pub fn try_acquire_lock(
173 version_path: &str,
174 lock_file_path: &str,
175) -> Option<Box<dyn FnOnce() + Send>> {
176 let version_name = Path::new(version_path)
177 .file_name()
178 .and_then(|n| n.to_str())
179 .unwrap_or("unknown")
180 .to_string();
181
182 if is_lock_active(lock_file_path) {
183 if read_lock_content(lock_file_path).is_some() {
184 return None;
185 }
186 }
187
188 let lock_content = VersionLockContent {
189 pid: std::process::id(),
190 version: version_name.clone(),
191 exec_path: std::env::current_exe()
192 .ok()
193 .and_then(|p| p.to_str().map(|s| s.to_string()))
194 .unwrap_or_default(),
195 acquired_at: SystemTime::now()
196 .duration_since(SystemTime::UNIX_EPOCH)
197 .unwrap()
198 .as_millis() as u64,
199 };
200
201 let json = match serde_json::to_string_pretty(&lock_content) {
202 Ok(j) => j,
203 Err(_) => return None,
204 };
205
206 if let Err(_) = std::fs::write(lock_file_path, &json) {
207 return None;
208 }
209
210 if let Some(verify_content) = read_lock_content(lock_file_path) {
211 if verify_content.pid != std::process::id() {
212 return None;
213 }
214 } else {
215 return None;
216 }
217
218 let lock_path = lock_file_path.to_string();
219 Some(Box::new(move || {
220 if let Some(current_content) = read_lock_content(&lock_path) {
221 if current_content.pid == std::process::id() {
222 let _ = std::fs::remove_file(&lock_path);
223 }
224 }
225 }))
226}
227
228pub fn acquire_process_lifetime_lock(version_path: &str, lock_file_path: &str) -> bool {
229 if let Some(release) = try_acquire_lock(version_path, lock_file_path) {
230 std::mem::forget(release);
231 true
232 } else {
233 false
234 }
235}
236
237pub fn with_lock<F>(version_path: &str, lock_file_path: &str, callback: F) -> bool
238where
239 F: FnOnce() + Send,
240{
241 if let Some(_release) = try_acquire_lock(version_path, lock_file_path) {
242 callback();
243 true
244 } else {
245 false
246 }
247}
248
249pub fn get_all_lock_info(locks_dir: &str) -> Vec<LockInfo> {
250 let mut lock_infos = Vec::new();
251 let path = Path::new(locks_dir);
252
253 if !path.is_dir() {
254 return lock_infos;
255 }
256
257 if let Ok(lock_files) = std::fs::read_dir(path) {
258 for lock_entry in lock_files.flatten() {
259 let lock_file_path = lock_entry.path();
260 if let Some(file_name) = lock_file_path.file_name() {
261 if file_name.to_string_lossy().ends_with(".lock") {
262 if let Some(content) = read_lock_content(&lock_file_path.to_string_lossy()) {
263 lock_infos.push(LockInfo {
264 version: content.version,
265 pid: content.pid,
266 is_process_running: is_process_running(content.pid),
267 exec_path: content.exec_path,
268 acquired_at: SystemTime::UNIX_EPOCH
269 + std::time::Duration::from_millis(content.acquired_at),
270 lock_file_path: lock_file_path.to_string_lossy().to_string(),
271 });
272 }
273 }
274 }
275 }
276 }
277
278 lock_infos
279}
280
281pub fn cleanup_stale_locks(locks_dir: &str) -> usize {
282 let path = Path::new(locks_dir);
283 let mut cleaned_count = 0;
284
285 if !path.is_dir() {
286 return 0;
287 }
288
289 if let Ok(lock_entries) = std::fs::read_dir(path) {
290 for lock_entry in lock_entries.flatten() {
291 let lock_path = lock_entry.path();
292 if let Some(file_name) = lock_path.file_name() {
293 if file_name.to_string_lossy().ends_with(".lock") {
294 if lock_path.is_dir() {
295 if std::fs::remove_dir_all(&lock_path).is_ok() {
296 cleaned_count += 1;
297 }
298 } else if !is_lock_active(&lock_path.to_string_lossy()) {
299 if std::fs::remove_file(&lock_path).is_ok() {
300 cleaned_count += 1;
301 }
302 }
303 }
304 }
305 }
306 }
307
308 cleaned_count
309}
310
311fn is_distro_family(os_release: &OsRelease, families: &[&str]) -> bool {
312 if families.iter().any(|f| *f == os_release.id) {
313 return true;
314 }
315 os_release
316 .id_like
317 .iter()
318 .any(|like| families.contains(&like.as_str()))
319}
320
321pub fn get_os_release() -> Option<OsRelease> {
322 let mut cache = OS_RELEASE_CACHE.lock().ok()?;
323 if cache.is_some() {
324 return cache.clone();
325 }
326
327 let content = std::fs::read_to_string("/etc/os-release").ok()?;
328
329 let mut id = String::new();
330 let mut id_like = Vec::new();
331
332 for line in content.lines() {
333 if let Some(prefix) = line.strip_prefix("ID=") {
334 id = prefix.trim_matches('"').trim_matches('\'').to_string();
335 }
336 if let Some(prefix) = line.strip_prefix("ID_LIKE=") {
337 let value = prefix.trim_matches('"').trim_matches('\'');
338 id_like = value.split_whitespace().map(|s| s.to_string()).collect();
339 }
340 }
341
342 if id.is_empty() {
343 return None;
344 }
345
346 let result = OsRelease { id, id_like };
347 *cache = Some(result.clone());
348 Some(result)
349}
350
351pub fn detect_mise() -> bool {
352 let exec_path = std::env::current_exe()
353 .ok()
354 .and_then(|p| p.to_str().map(|s| s.to_string()))
355 .unwrap_or_default();
356
357 exec_path.contains("/mise/installs/") || exec_path.contains("\\mise\\installs\\")
358}
359
360pub fn detect_asdf() -> bool {
361 let exec_path = std::env::current_exe()
362 .ok()
363 .and_then(|p| p.to_str().map(|s| s.to_string()))
364 .unwrap_or_default();
365
366 exec_path.contains("/.asdf/installs/")
367 || exec_path.contains("\\.asdf\\installs\\")
368 || exec_path.contains("/asdf/installs/")
369 || exec_path.contains("\\asdf\\installs\\")
370}
371
372pub fn detect_homebrew() -> bool {
373 let platform = get_platform();
374
375 if platform != "macos" && platform != "linux" && platform != "wsl" {
376 return false;
377 }
378
379 let exec_path = std::env::current_exe()
380 .ok()
381 .and_then(|p| p.to_str().map(|s| s.to_string()))
382 .unwrap_or_default();
383
384 exec_path.contains("/Caskroom/")
385}
386
387pub fn detect_winget() -> bool {
388 let platform = get_platform();
389
390 if platform != "windows" {
391 return false;
392 }
393
394 let exec_path = std::env::current_exe()
395 .ok()
396 .and_then(|p| p.to_str().map(|s| s.to_string()))
397 .unwrap_or_default();
398
399 exec_path.contains("Microsoft\\WinGet\\Packages")
400 || exec_path.contains("Microsoft/WinGet/Packages")
401 || exec_path.contains("Microsoft\\WinGet\\Links")
402 || exec_path.contains("Microsoft/WinGet/Links")
403}
404
405pub async fn detect_pacman() -> bool {
406 let platform = get_platform();
407
408 if platform != "linux" {
409 return false;
410 }
411
412 if let Some(os_release) = get_os_release() {
413 if !is_distro_family(&os_release, &["arch"]) {
414 return false;
415 }
416 }
417
418 let exec_path = std::env::current_exe()
419 .ok()
420 .and_then(|p| p.to_str().map(|s| s.to_string()))
421 .unwrap_or_default();
422
423 let output = tokio::process::Command::new("pacman")
424 .args(["-Qo", &exec_path])
425 .output()
426 .await;
427
428 match output {
429 Ok(o) => o.status.success() && !o.stdout.is_empty(),
430 Err(_) => false,
431 }
432}
433
434pub async fn detect_deb() -> bool {
435 let platform = get_platform();
436
437 if platform != "linux" {
438 return false;
439 }
440
441 if let Some(os_release) = get_os_release() {
442 if !is_distro_family(&os_release, &["debian"]) {
443 return false;
444 }
445 }
446
447 let exec_path = std::env::current_exe()
448 .ok()
449 .and_then(|p| p.to_str().map(|s| s.to_string()))
450 .unwrap_or_default();
451
452 let output = tokio::process::Command::new("dpkg")
453 .args(["-S", &exec_path])
454 .output()
455 .await;
456
457 match output {
458 Ok(o) => o.status.success() && !o.stdout.is_empty(),
459 Err(_) => false,
460 }
461}
462
463pub async fn detect_rpm() -> bool {
464 let platform = get_platform();
465
466 if platform != "linux" {
467 return false;
468 }
469
470 if let Some(os_release) = get_os_release() {
471 if !is_distro_family(&os_release, &["fedora", "rhel", "suse"]) {
472 return false;
473 }
474 }
475
476 let exec_path = std::env::current_exe()
477 .ok()
478 .and_then(|p| p.to_str().map(|s| s.to_string()))
479 .unwrap_or_default();
480
481 let output = tokio::process::Command::new("rpm")
482 .args(["-qf", &exec_path])
483 .output()
484 .await;
485
486 match output {
487 Ok(o) => o.status.success() && !o.stdout.is_empty(),
488 Err(_) => false,
489 }
490}
491
492pub async fn detect_apk() -> bool {
493 let platform = get_platform();
494
495 if platform != "linux" {
496 return false;
497 }
498
499 if let Some(os_release) = get_os_release() {
500 if !is_distro_family(&os_release, &["alpine"]) {
501 return false;
502 }
503 }
504
505 let exec_path = std::env::current_exe()
506 .ok()
507 .and_then(|p| p.to_str().map(|s| s.to_string()))
508 .unwrap_or_default();
509
510 let output = tokio::process::Command::new("apk")
511 .args(["info", "--who-owns", &exec_path])
512 .output()
513 .await;
514
515 match output {
516 Ok(o) => o.status.success() && !o.stdout.is_empty(),
517 Err(_) => false,
518 }
519}
520
521pub async fn get_package_manager() -> PackageManager {
522 if let Ok(cache) = PACKAGE_MANAGER_CACHE.lock() {
523 if let Some(pm) = *cache {
524 return pm;
525 }
526 }
527
528 let pm = get_package_manager_impl().await;
529
530 if let Ok(mut cache) = PACKAGE_MANAGER_CACHE.lock() {
531 *cache = Some(pm);
532 }
533
534 pm
535}
536
537async fn get_package_manager_impl() -> PackageManager {
538 if detect_homebrew() {
539 return PackageManager::Homebrew;
540 }
541
542 if detect_winget() {
543 return PackageManager::Winget;
544 }
545
546 if detect_mise() {
547 return PackageManager::Mise;
548 }
549
550 if detect_asdf() {
551 return PackageManager::Asdf;
552 }
553
554 if detect_pacman().await {
555 return PackageManager::Pacman;
556 }
557
558 if detect_apk().await {
559 return PackageManager::Apk;
560 }
561
562 if detect_deb().await {
563 return PackageManager::Deb;
564 }
565
566 if detect_rpm().await {
567 return PackageManager::Rpm;
568 }
569
570 PackageManager::Unknown
571}
572
573pub fn is_native_installer_available() -> bool {
574 #[cfg(target_os = "macos")]
575 {
576 Command::new("brew")
577 .arg("--version")
578 .output()
579 .map(|o| o.status.success())
580 .unwrap_or(false)
581 }
582
583 #[cfg(target_os = "linux")]
584 {
585 Command::new("apt-get")
586 .arg("--version")
587 .output()
588 .map(|o| o.status.success())
589 .unwrap_or(false)
590 }
591
592 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
593 {
594 false
595 }
596}
597
598pub fn install_package(package: &str) -> Result<(), String> {
599 #[cfg(target_os = "macos")]
600 {
601 let output = Command::new("brew")
602 .args(["install", package])
603 .output()
604 .map_err(|e| e.to_string())?;
605
606 if output.status.success() {
607 Ok(())
608 } else {
609 Err(String::from_utf8_lossy(&output.stderr).to_string())
610 }
611 }
612
613 #[cfg(target_os = "linux")]
614 {
615 let output = Command::new("sudo")
616 .args(["apt-get", "install", "-y", package])
617 .output()
618 .map_err(|e| e.to_string())?;
619
620 if output.status.success() {
621 Ok(())
622 } else {
623 Err(String::from_utf8_lossy(&output.stderr).to_string())
624 }
625 }
626
627 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
628 {
629 Err("Unsupported platform".to_string())
630 }
631}