Skip to main content

debugger/setup/
installer.rs

1//! Core installation traits and logic
2//!
3//! Defines the Installer trait and common installation utilities.
4
5use 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/// Installation status of a debugger
14#[derive(Debug, Clone)]
15pub enum InstallStatus {
16    /// Not installed
17    NotInstalled,
18    /// Installed at path, with optional version
19    Installed {
20        path: PathBuf,
21        version: Option<String>,
22    },
23    /// Installed but not working
24    Broken { path: PathBuf, reason: String },
25}
26
27/// Installation method for a debugger
28#[derive(Debug, Clone)]
29pub enum InstallMethod {
30    /// Use system package manager
31    PackageManager {
32        manager: PackageManager,
33        package: String,
34    },
35    /// Download from GitHub releases
36    GitHubRelease {
37        repo: String,
38        asset_pattern: String,
39    },
40    /// Download from direct URL
41    DirectDownload { url: String },
42    /// Use language-specific package manager
43    LanguagePackage { tool: String, package: String },
44    /// Extract from VS Code extension
45    VsCodeExtension { extension_id: String },
46    /// Already available in PATH
47    AlreadyInstalled { path: PathBuf },
48    /// Cannot install on this platform
49    NotSupported { reason: String },
50}
51
52/// Package managers
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum PackageManager {
55    // Linux
56    Apt,
57    Dnf,
58    Pacman,
59    // macOS
60    Homebrew,
61    // Windows
62    Winget,
63    Scoop,
64    // Cross-platform
65    Cargo,
66    Pip,
67    Go,
68}
69
70impl PackageManager {
71    /// Detect available package managers
72    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    /// Get install command for a package
107    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/// Options for installation
123#[derive(Debug, Clone, Default)]
124pub struct InstallOptions {
125    /// Specific version to install
126    pub version: Option<String>,
127    /// Force reinstall
128    pub force: bool,
129}
130
131/// Result of an installation
132#[derive(Debug, Clone)]
133pub struct InstallResult {
134    /// Path to the installed binary
135    pub path: PathBuf,
136    /// Installed version
137    pub version: Option<String>,
138    /// Additional arguments needed to run the adapter
139    pub args: Vec<String>,
140}
141
142/// Trait for debugger installers
143#[async_trait]
144pub trait Installer: Send + Sync {
145    /// Get debugger metadata
146    fn info(&self) -> &DebuggerInfo;
147
148    /// Check current installation status
149    async fn status(&self) -> Result<InstallStatus>;
150
151    /// Find the best installation method for current platform
152    async fn best_method(&self) -> Result<InstallMethod>;
153
154    /// Install the debugger
155    async fn install(&self, opts: InstallOptions) -> Result<InstallResult>;
156
157    /// Uninstall the debugger
158    async fn uninstall(&self) -> Result<()>;
159
160    /// Verify the installation works
161    async fn verify(&self) -> Result<VerifyResult>;
162}
163
164/// Get the adapters installation directory
165pub 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            // Fallback to platform-specific paths
170            #[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
197/// Ensure the adapters directory exists
198pub 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
206/// Download a file with progress reporting
207pub 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
262/// Extract a zip archive
263pub 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        // Set permissions on Unix
291        #[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
303/// Extract a tar.gz archive
304pub 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
316/// Extract a tar.xz archive
317pub 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/// Make a file executable on Unix
330#[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
344/// Run a shell command and return output
345/// Note: This should only be used for trusted commands. For user input, use run_command_args.
346pub 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
369/// Run a command with explicit arguments (safe from shell injection)
370pub 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
392/// Query GitHub API for latest release with retry logic
393pub 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    // Retry with exponential backoff (1s, 2s, 4s)
405    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        // Check for rate limiting
428        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            // Don't retry on 404 or other client errors
441            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/// GitHub release information
461#[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/// GitHub release asset
469#[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    /// Find an asset matching a pattern
478    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
494/// Get current platform string for asset matching
495pub fn platform_str() -> &'static str {
496    match Platform::current() {
497        Platform::Linux => "linux",
498        Platform::MacOS => "darwin",
499        Platform::Windows => "windows",
500    }
501}
502
503/// Get current architecture string for asset matching
504pub 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
518/// Write version info to a file
519pub 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
525/// Read version from a version file
526pub 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}