use anyhow::{anyhow, Result};
use serde::Deserialize;
const MARKETPLACE_ORIGIN: &str = "https://marketplace.visualstudio.com";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtensionRef {
pub publisher: String,
pub name: String,
}
impl ExtensionRef {
pub fn item_name(&self) -> String {
format!("{}.{}", self.publisher, self.name)
}
}
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 })
}
pub fn build_download_url(ext: &ExtensionRef, version: &str) -> String {
format!(
"{MARKETPLACE_ORIGIN}/_apis/public/gallery/publishers/{}/vsextensions/{}/{version}/vspackage",
ext.publisher, ext.name
)
}
pub fn build_vsix_filename(ext: &ExtensionRef, version: &str) -> String {
format!("{}.{}-{version}.vsix", ext.publisher, ext.name)
}
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>,
}
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");
}
}