1use super::registry::{DebuggerInfo, Platform};
6use super::verifier::VerifyResult;
7use crate::common::{Error, Result};
8use async_trait::async_trait;
9use futures_util::StreamExt;
10use indicatif::{ProgressBar, ProgressStyle};
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone)]
15pub enum InstallStatus {
16 NotInstalled,
18 Installed {
20 path: PathBuf,
21 version: Option<String>,
22 },
23 Broken { path: PathBuf, reason: String },
25}
26
27#[derive(Debug, Clone)]
29pub enum InstallMethod {
30 PackageManager {
32 manager: PackageManager,
33 package: String,
34 },
35 GitHubRelease {
37 repo: String,
38 asset_pattern: String,
39 },
40 DirectDownload { url: String },
42 LanguagePackage { tool: String, package: String },
44 VsCodeExtension { extension_id: String },
46 AlreadyInstalled { path: PathBuf },
48 NotSupported { reason: String },
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum PackageManager {
55 Apt,
57 Dnf,
58 Pacman,
59 Homebrew,
61 Winget,
63 Scoop,
64 Cargo,
66 Pip,
67 Go,
68}
69
70impl PackageManager {
71 pub fn detect() -> Vec<PackageManager> {
73 let mut found = Vec::new();
74
75 if which::which("apt").is_ok() {
76 found.push(PackageManager::Apt);
77 }
78 if which::which("dnf").is_ok() {
79 found.push(PackageManager::Dnf);
80 }
81 if which::which("pacman").is_ok() {
82 found.push(PackageManager::Pacman);
83 }
84 if which::which("brew").is_ok() {
85 found.push(PackageManager::Homebrew);
86 }
87 if which::which("winget").is_ok() {
88 found.push(PackageManager::Winget);
89 }
90 if which::which("scoop").is_ok() {
91 found.push(PackageManager::Scoop);
92 }
93 if which::which("cargo").is_ok() {
94 found.push(PackageManager::Cargo);
95 }
96 if which::which("pip3").is_ok() || which::which("pip").is_ok() {
97 found.push(PackageManager::Pip);
98 }
99 if which::which("go").is_ok() {
100 found.push(PackageManager::Go);
101 }
102
103 found
104 }
105
106 pub fn install_command(&self, package: &str) -> String {
108 match self {
109 PackageManager::Apt => format!("sudo apt install -y {}", package),
110 PackageManager::Dnf => format!("sudo dnf install -y {}", package),
111 PackageManager::Pacman => format!("sudo pacman -S --noconfirm {}", package),
112 PackageManager::Homebrew => format!("brew install {}", package),
113 PackageManager::Winget => format!("winget install {}", package),
114 PackageManager::Scoop => format!("scoop install {}", package),
115 PackageManager::Cargo => format!("cargo install {}", package),
116 PackageManager::Pip => format!("pip3 install {}", package),
117 PackageManager::Go => format!("go install {}", package),
118 }
119 }
120}
121
122#[derive(Debug, Clone, Default)]
124pub struct InstallOptions {
125 pub version: Option<String>,
127 pub force: bool,
129}
130
131#[derive(Debug, Clone)]
133pub struct InstallResult {
134 pub path: PathBuf,
136 pub version: Option<String>,
138 pub args: Vec<String>,
140}
141
142#[async_trait]
144pub trait Installer: Send + Sync {
145 fn info(&self) -> &DebuggerInfo;
147
148 async fn status(&self) -> Result<InstallStatus>;
150
151 async fn best_method(&self) -> Result<InstallMethod>;
153
154 async fn install(&self, opts: InstallOptions) -> Result<InstallResult>;
156
157 async fn uninstall(&self) -> Result<()>;
159
160 async fn verify(&self) -> Result<VerifyResult>;
162}
163
164pub fn adapters_dir() -> PathBuf {
166 let base = directories::ProjectDirs::from("", "", "debugger-cli")
167 .map(|dirs| dirs.data_dir().to_path_buf())
168 .unwrap_or_else(|| {
169 #[cfg(target_os = "linux")]
171 let fallback = std::env::var("HOME")
172 .map(PathBuf::from)
173 .unwrap_or_else(|_| PathBuf::from("."))
174 .join(".local/share/debugger-cli");
175
176 #[cfg(target_os = "macos")]
177 let fallback = std::env::var("HOME")
178 .map(PathBuf::from)
179 .unwrap_or_else(|_| PathBuf::from("."))
180 .join("Library/Application Support/debugger-cli");
181
182 #[cfg(target_os = "windows")]
183 let fallback = std::env::var("LOCALAPPDATA")
184 .map(PathBuf::from)
185 .unwrap_or_else(|_| PathBuf::from("."))
186 .join("debugger-cli");
187
188 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
189 let fallback = PathBuf::from(".").join("debugger-cli");
190
191 fallback
192 });
193
194 base.join("adapters")
195}
196
197pub fn ensure_adapters_dir() -> Result<PathBuf> {
199 let dir = adapters_dir();
200 if !dir.exists() {
201 std::fs::create_dir_all(&dir)?;
202 }
203 Ok(dir)
204}
205
206pub async fn download_file(url: &str, dest: &Path) -> Result<()> {
208 let client = reqwest::Client::new();
209 let response = client
210 .get(url)
211 .header("User-Agent", "debugger-cli")
212 .send()
213 .await
214 .map_err(|e| Error::Internal(format!("Failed to download {}: {}", url, e)))?;
215
216 if !response.status().is_success() {
217 return Err(Error::Internal(format!(
218 "Download failed with status {}: {}",
219 response.status(),
220 url
221 )));
222 }
223
224 let total_size = response.content_length().unwrap_or(0);
225
226 let pb = if total_size > 0 {
227 let pb = ProgressBar::new(total_size);
228 pb.set_style(
229 ProgressStyle::default_bar()
230 .template(" [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
231 .unwrap()
232 .progress_chars("=> "),
233 );
234 Some(pb)
235 } else {
236 println!(" Downloading...");
237 None
238 };
239
240 let mut file =
241 std::fs::File::create(dest).map_err(|e| Error::Internal(format!("Failed to create file: {}", e)))?;
242
243 let mut stream = response.bytes_stream();
244 let mut downloaded: u64 = 0;
245
246 while let Some(chunk) = stream.next().await {
247 let chunk = chunk.map_err(|e| Error::Internal(format!("Download error: {}", e)))?;
248 std::io::Write::write_all(&mut file, &chunk)?;
249 downloaded += chunk.len() as u64;
250 if let Some(ref pb) = pb {
251 pb.set_position(downloaded);
252 }
253 }
254
255 if let Some(pb) = pb {
256 pb.finish_and_clear();
257 }
258
259 Ok(())
260}
261
262pub fn extract_zip(archive_path: &Path, dest_dir: &Path) -> Result<()> {
264 let file = std::fs::File::open(archive_path)?;
265 let mut archive = zip::ZipArchive::new(file)
266 .map_err(|e| Error::Internal(format!("Failed to open zip: {}", e)))?;
267
268 for i in 0..archive.len() {
269 let mut file = archive
270 .by_index(i)
271 .map_err(|e| Error::Internal(format!("Failed to read zip entry: {}", e)))?;
272
273 let outpath = match file.enclosed_name() {
274 Some(path) => dest_dir.join(path),
275 None => continue,
276 };
277
278 if file.is_dir() {
279 std::fs::create_dir_all(&outpath)?;
280 } else {
281 if let Some(parent) = outpath.parent() {
282 if !parent.exists() {
283 std::fs::create_dir_all(parent)?;
284 }
285 }
286 let mut outfile = std::fs::File::create(&outpath)?;
287 std::io::copy(&mut file, &mut outfile)?;
288 }
289
290 #[cfg(unix)]
292 {
293 use std::os::unix::fs::PermissionsExt;
294 if let Some(mode) = file.unix_mode() {
295 std::fs::set_permissions(&outpath, std::fs::Permissions::from_mode(mode))?;
296 }
297 }
298 }
299
300 Ok(())
301}
302
303pub fn extract_tar_gz(archive_path: &Path, dest_dir: &Path) -> Result<()> {
305 let file = std::fs::File::open(archive_path)?;
306 let decoder = flate2::read::GzDecoder::new(file);
307 let mut archive = tar::Archive::new(decoder);
308
309 archive
310 .unpack(dest_dir)
311 .map_err(|e| Error::Internal(format!("Failed to extract tar.gz: {}", e)))?;
312
313 Ok(())
314}
315
316pub fn extract_tar_xz(archive_path: &Path, dest_dir: &Path) -> Result<()> {
318 let file = std::fs::File::open(archive_path)?;
319 let decoder = xz2::read::XzDecoder::new(file);
320 let mut archive = tar::Archive::new(decoder);
321
322 archive
323 .unpack(dest_dir)
324 .map_err(|e| Error::Internal(format!("Failed to extract tar.xz: {}", e)))?;
325
326 Ok(())
327}
328
329#[cfg(unix)]
331pub fn make_executable(path: &Path) -> Result<()> {
332 use std::os::unix::fs::PermissionsExt;
333 let mut perms = std::fs::metadata(path)?.permissions();
334 perms.set_mode(perms.mode() | 0o755);
335 std::fs::set_permissions(path, perms)?;
336 Ok(())
337}
338
339#[cfg(not(unix))]
340pub fn make_executable(_path: &Path) -> Result<()> {
341 Ok(())
342}
343
344pub async fn run_command(command: &str) -> Result<String> {
347 let output = if cfg!(windows) {
348 tokio::process::Command::new("cmd")
349 .args(["/C", command])
350 .output()
351 .await
352 } else {
353 tokio::process::Command::new("sh")
354 .args(["-c", command])
355 .output()
356 .await
357 };
358
359 let output = output.map_err(|e| Error::Internal(format!("Failed to run command: {}", e)))?;
360
361 if !output.status.success() {
362 let stderr = String::from_utf8_lossy(&output.stderr);
363 return Err(Error::Internal(format!("Command failed: {}", stderr)));
364 }
365
366 Ok(String::from_utf8_lossy(&output.stdout).to_string())
367}
368
369pub async fn run_command_args<S: AsRef<std::ffi::OsStr>>(
371 program: &Path,
372 args: &[S],
373) -> Result<String> {
374 let output = tokio::process::Command::new(program)
375 .args(args)
376 .output()
377 .await
378 .map_err(|e| Error::Internal(format!("Failed to run {}: {}", program.display(), e)))?;
379
380 if !output.status.success() {
381 let stderr = String::from_utf8_lossy(&output.stderr);
382 return Err(Error::Internal(format!(
383 "{} failed: {}",
384 program.display(),
385 stderr
386 )));
387 }
388
389 Ok(String::from_utf8_lossy(&output.stdout).to_string())
390}
391
392pub async fn get_github_release(repo: &str, version: Option<&str>) -> Result<GitHubRelease> {
394 let client = reqwest::Client::new();
395 let url = if let Some(v) = version {
396 format!(
397 "https://api.github.com/repos/{}/releases/tags/{}",
398 repo, v
399 )
400 } else {
401 format!("https://api.github.com/repos/{}/releases/latest", repo)
402 };
403
404 let delays = [1, 2, 4];
406 let mut last_error = None;
407
408 for (attempt, delay) in std::iter::once(0).chain(delays.iter().copied()).enumerate() {
409 if attempt > 0 {
410 tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
411 }
412
413 let response = match client
414 .get(&url)
415 .header("User-Agent", "debugger-cli")
416 .header("Accept", "application/vnd.github.v3+json")
417 .send()
418 .await
419 {
420 Ok(r) => r,
421 Err(e) => {
422 last_error = Some(format!("GitHub API error: {}", e));
423 continue;
424 }
425 };
426
427 if response.status() == reqwest::StatusCode::FORBIDDEN
429 || response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS
430 {
431 last_error = Some(
432 "GitHub API rate limit exceeded. Set GITHUB_TOKEN env var to increase limit."
433 .to_string(),
434 );
435 continue;
436 }
437
438 if !response.status().is_success() {
439 last_error = Some(format!("GitHub API returned status {}", response.status()));
440 if response.status().is_client_error() {
442 break;
443 }
444 continue;
445 }
446
447 let release: GitHubRelease = response
448 .json()
449 .await
450 .map_err(|e| Error::Internal(format!("Failed to parse GitHub response: {}", e)))?;
451
452 return Ok(release);
453 }
454
455 Err(Error::Internal(
456 last_error.unwrap_or_else(|| "GitHub API request failed".to_string()),
457 ))
458}
459
460#[derive(Debug, serde::Deserialize)]
462pub struct GitHubRelease {
463 pub tag_name: String,
464 pub name: Option<String>,
465 pub assets: Vec<GitHubAsset>,
466}
467
468#[derive(Debug, serde::Deserialize)]
470pub struct GitHubAsset {
471 pub name: String,
472 pub browser_download_url: String,
473 pub size: u64,
474}
475
476impl GitHubRelease {
477 pub fn find_asset(&self, patterns: &[&str]) -> Option<&GitHubAsset> {
479 for pattern in patterns {
480 if let Some(asset) = self.assets.iter().find(|a| {
481 let name = a.name.to_lowercase();
482 pattern
483 .to_lowercase()
484 .split('*')
485 .all(|part| name.contains(part))
486 }) {
487 return Some(asset);
488 }
489 }
490 None
491 }
492}
493
494pub fn platform_str() -> &'static str {
496 match Platform::current() {
497 Platform::Linux => "linux",
498 Platform::MacOS => "darwin",
499 Platform::Windows => "windows",
500 }
501}
502
503pub fn arch_str() -> &'static str {
505 #[cfg(target_arch = "x86_64")]
506 return "x86_64";
507
508 #[cfg(target_arch = "aarch64")]
509 return "aarch64";
510
511 #[cfg(target_arch = "x86")]
512 return "i686";
513
514 #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64", target_arch = "x86")))]
515 return "unknown";
516}
517
518pub fn write_version_file(dir: &Path, version: &str) -> Result<()> {
520 let version_file = dir.join("version.txt");
521 std::fs::write(&version_file, version)?;
522 Ok(())
523}
524
525pub fn read_version_file(dir: &Path) -> Option<String> {
527 let version_file = dir.join("version.txt");
528 std::fs::read_to_string(&version_file)
529 .ok()
530 .map(|s| s.trim().to_string())
531}