vkit 0.1.4

Fast Rust dev CLI: manage git worktrees, Node ports, run scripts, install & sync VS Code / Cursor extensions.
//! VS Code Marketplace 交互:解析输入、拼下载地址、查询最新版本。
//!
//! 纯函数(解析 / 拼串)与网络请求分离,前者可单测。

use anyhow::{anyhow, Result};
use serde::Deserialize;

const MARKETPLACE_ORIGIN: &str = "https://marketplace.visualstudio.com";

/// 一个扩展引用:publisher + name。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtensionRef {
    pub publisher: String,
    pub name: String,
}

impl ExtensionRef {
    /// `publisher.name` 形式的完整 id。
    pub fn item_name(&self) -> String {
        format!("{}.{}", self.publisher, self.name)
    }
}

/// 解析用户输入:支持 `publisher.name` 或含 `itemName=` 的 Marketplace 链接。
pub fn parse_extension_input(raw: &str) -> Option<ExtensionRef> {
    let input = raw.trim();
    if input.is_empty() {
        return None;
    }

    let mut item = input.to_string();
    if let Some(pos) = input.find("itemName=") {
        let rest = &input[pos + "itemName=".len()..];
        let value: String = rest
            .chars()
            .take_while(|c| *c != '&' && !c.is_whitespace())
            .collect();
        if !value.is_empty() {
            item = percent_decode(&value);
        }
    }

    let dot = item.find('.')?;
    if dot == 0 || dot == item.len() - 1 {
        return None;
    }
    let publisher = item[..dot].trim().to_string();
    let name = item[dot + 1..].trim().to_string();
    if publisher.is_empty() || name.is_empty() {
        return None;
    }

    Some(ExtensionRef { publisher, name })
}

/// 下载 vspackage 的地址。
pub fn build_download_url(ext: &ExtensionRef, version: &str) -> String {
    format!(
        "{MARKETPLACE_ORIGIN}/_apis/public/gallery/publishers/{}/vsextensions/{}/{version}/vspackage",
        ext.publisher, ext.name
    )
}

/// 落盘用的 vsix 文件名。
pub fn build_vsix_filename(ext: &ExtensionRef, version: &str) -> String {
    format!("{}.{}-{version}.vsix", ext.publisher, ext.name)
}

/// 最小 percent-decode(只处理 %XX;其余原样保留)。
fn percent_decode(input: &str) -> String {
    let bytes = input.as_bytes();
    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%' && i + 2 < bytes.len() {
            let hi = (bytes[i + 1] as char).to_digit(16);
            let lo = (bytes[i + 2] as char).to_digit(16);
            if let (Some(hi), Some(lo)) = (hi, lo) {
                out.push((hi * 16 + lo) as u8);
                i += 3;
                continue;
            }
        }
        out.push(bytes[i]);
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

/// 解析出的扩展信息。
pub struct Resolved {
    pub version: String,
    pub display_name: String,
}

#[derive(Deserialize)]
struct QueryResponse {
    results: Option<Vec<QueryResult>>,
}
#[derive(Deserialize)]
struct QueryResult {
    extensions: Option<Vec<QueryExtension>>,
}
#[derive(Deserialize)]
struct QueryExtension {
    #[serde(rename = "displayName")]
    display_name: Option<String>,
    versions: Option<Vec<QueryVersion>>,
}
#[derive(Deserialize)]
struct QueryVersion {
    version: Option<String>,
}

/// 查询扩展的最新版本与展示名(Marketplace extensionquery API)。
pub fn resolve_extension(ext: &ExtensionRef) -> Result<Resolved> {
    let item = ext.item_name();
    let body = serde_json::json!({
        "filters": [{
            "criteria": [
                {"filterType": 8, "value": "Microsoft.VisualStudio.Code"},
                {"filterType": 7, "value": item},
            ],
            "pageNumber": 1,
            "pageSize": 1,
            "sortBy": 0,
            "sortOrder": 0,
        }],
        "flags": 513,
    });

    let response = ureq::post(&format!(
        "{MARKETPLACE_ORIGIN}/_apis/public/gallery/extensionquery"
    ))
    .header("Accept", "application/json;api-version=3.0-preview.1")
    .send_json(&body);

    let mut response = match response {
        Ok(response) => response,
        Err(ureq::Error::StatusCode(code)) => {
            return Err(anyhow!("Marketplace API 请求失败(HTTP {code})"));
        }
        Err(err) => return Err(anyhow!("请求 Marketplace 失败:{err}")),
    };

    let data: QueryResponse = response.body_mut().read_json()?;
    let extension = data
        .results
        .and_then(|mut results| results.drain(..).next())
        .and_then(|result| result.extensions)
        .and_then(|mut extensions| extensions.drain(..).next())
        .ok_or_else(|| anyhow!("未找到扩展 \"{item}\",请检查名称是否正确"))?;

    let version = extension
        .versions
        .and_then(|mut versions| versions.drain(..).next())
        .and_then(|v| v.version)
        .ok_or_else(|| anyhow!("未找到扩展 \"{item}\" 的可用版本"))?;

    let display_name = extension.display_name.unwrap_or_else(|| item.clone());

    Ok(Resolved {
        version,
        display_name,
    })
}

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

    #[test]
    fn parses_plain_id() {
        let ext = parse_extension_input("ms-python.python").unwrap();
        assert_eq!(ext.publisher, "ms-python");
        assert_eq!(ext.name, "python");
    }

    #[test]
    fn parses_marketplace_url() {
        let ext = parse_extension_input(
            "https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode",
        )
        .unwrap();
        assert_eq!(ext.publisher, "esbenp");
        assert_eq!(ext.name, "prettier-vscode");
    }

    #[test]
    fn rejects_invalid() {
        assert!(parse_extension_input("").is_none());
        assert!(parse_extension_input("nodot").is_none());
        assert!(parse_extension_input(".leading").is_none());
        assert!(parse_extension_input("trailing.").is_none());
    }

    #[test]
    fn builds_url_and_filename() {
        let ext = ExtensionRef {
            publisher: "ms-python".into(),
            name: "python".into(),
        };
        assert_eq!(
            build_download_url(&ext, "2024.1.0"),
            "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-python/vsextensions/python/2024.1.0/vspackage"
        );
        assert_eq!(
            build_vsix_filename(&ext, "2024.1.0"),
            "ms-python.python-2024.1.0.vsix"
        );
    }

    #[test]
    fn percent_decode_handles_encoded_chars() {
        assert_eq!(percent_decode("a%2Eb"), "a.b");
        assert_eq!(percent_decode("plain"), "plain");
    }
}