vkit 0.1.1

Fast Rust dev CLI: manage Node ports, run scripts, install & sync VS Code / Cursor extensions.
//! 下载 vsix:解析输入 → 查询版本 → 流式写入 ~/Downloads。

use std::fs::{self, File};
use std::io;
use std::path::{Path, PathBuf};

use anyhow::{anyhow, Result};

use super::marketplace;

/// 下载成功的产物。
#[derive(Debug)]
pub struct Downloaded {
    pub path: PathBuf,
    /// 展示名(扩展 displayName,回退到 id)。
    pub label: String,
    pub version: String,
}

/// 默认下载目录:~/Downloads(拿不到主目录时回退当前目录)。
fn default_dir() -> PathBuf {
    dirs::download_dir()
        .or_else(|| dirs::home_dir().map(|home| home.join("Downloads")))
        .unwrap_or_else(|| PathBuf::from("."))
}

/// 解析输入并下载最新版本 vsix 到默认目录(~/Downloads)。
pub fn resolve_and_download(input: &str) -> Result<Downloaded> {
    resolve_and_download_to(input, None)
}

/// 解析输入并下载最新版本 vsix;`dir` 为 `None` 时落到默认目录。
pub fn resolve_and_download_to(input: &str, dir: Option<&Path>) -> Result<Downloaded> {
    let ext = marketplace::parse_extension_input(input).ok_or_else(|| {
        anyhow!("无法解析输入。请使用 \"publisher.extension\" 或完整的 Marketplace 链接")
    })?;

    let resolved = marketplace::resolve_extension(&ext)?;
    let url = marketplace::build_download_url(&ext, &resolved.version);
    let filename = marketplace::build_vsix_filename(&ext, &resolved.version);
    let dest = match dir {
        Some(dir) => dir.join(&filename),
        None => default_dir().join(&filename),
    };

    download_to_file(&url, &dest)?;

    Ok(Downloaded {
        path: dest,
        label: resolved.display_name,
        version: resolved.version,
    })
}

/// 流式下载到文件;出错时清理半成品。ureq 的 gzip 特性会按 Content-Encoding 自动解压。
fn download_to_file(url: &str, dest: &PathBuf) -> Result<()> {
    let response = match ureq::get(url).call() {
        Ok(response) => response,
        Err(ureq::Error::Status(code, _)) => return Err(anyhow!("下载失败(HTTP {code})")),
        Err(err) => return Err(anyhow!("下载失败:{err}")),
    };

    if let Some(parent) = dest.parent() {
        fs::create_dir_all(parent)?;
    }

    let mut reader = response.into_reader();
    let mut file = File::create(dest)?;
    if let Err(err) = io::copy(&mut reader, &mut file) {
        let _ = fs::remove_file(dest);
        return Err(anyhow!("写入文件失败:{err}"));
    }

    Ok(())
}

/// 展示用的文件大小(从磁盘读取)。
pub fn human_size(path: &PathBuf) -> String {
    let bytes = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
    let kb = bytes as f64 / 1024.0;
    if kb < 1024.0 {
        format!("{kb:.0} KB")
    } else {
        format!("{:.1} MB", kb / 1024.0)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn invalid_input_errors_before_network() {
        let err = resolve_and_download("not-an-extension").unwrap_err();
        assert!(err.to_string().contains("无法解析输入"));
    }
}