use std::time::Duration;
use serde::Deserialize;
use tracing::warn;
use crate::sources::{source_error, Source};
use crate::types::{
CompatStatus, DiscoveredPlugin, PluginCategory, PluginSource, SourceError, TrustTier,
};
use nexo_tool_meta::admin::plugin_install::{InstallSource, PluginsInstallParams};
use async_trait::async_trait;
pub const SOURCE_NAME: &str = "crates_io";
const MAX_PAGES: u32 = 10;
const QUERIES: &[&str] = &["nexo-plugin", "nexo-poller"];
pub struct CratesIoSource {
http: reqwest::Client,
endpoint: String,
}
impl CratesIoSource {
pub fn new(endpoint: impl Into<String>, http_timeout: Duration) -> Self {
let http = reqwest::Client::builder()
.user_agent(format!(
"nexo-plugin-discovery/{} (+https://github.com/lordmacu/nexo-rs)",
env!("CARGO_PKG_VERSION")
))
.timeout(http_timeout)
.build()
.expect("reqwest client build (rustls) failed");
Self {
http,
endpoint: endpoint.into(),
}
}
}
#[async_trait]
impl Source for CratesIoSource {
fn name(&self) -> &'static str {
SOURCE_NAME
}
async fn fetch(&self) -> Result<Vec<DiscoveredPlugin>, SourceError> {
let mut acc: Vec<DiscoveredPlugin> = Vec::new();
for query in QUERIES {
let mut page: u32 = 1;
loop {
let url = format!(
"{}/api/v1/crates?q={}&per_page=100&page={}",
self.endpoint.trim_end_matches('/'),
query,
page
);
let resp = self
.http
.get(&url)
.send()
.await
.map_err(|e| source_error(SOURCE_NAME, format!("GET {url}: {e}")))?;
if !resp.status().is_success() {
return Err(source_error(
SOURCE_NAME,
format!("GET {url}: status {}", resp.status()),
));
}
let parsed: CratesIoSearchPage = resp
.json()
.await
.map_err(|e| source_error(SOURCE_NAME, format!("parse {url}: {e}")))?;
let page_len = parsed.crates.len();
for raw in parsed.crates.into_iter() {
if let Some(plugin) = map_crate(raw) {
acc.push(plugin);
}
}
if page_len < 100 || page >= MAX_PAGES {
break;
}
page += 1;
}
}
Ok(acc)
}
}
#[derive(Debug, Deserialize)]
struct CratesIoSearchPage {
crates: Vec<CratesIoCrate>,
}
#[derive(Debug, Deserialize)]
struct CratesIoCrate {
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
repository: Option<String>,
#[serde(default)]
homepage: Option<String>,
#[serde(default)]
max_stable_version: Option<String>,
#[serde(default)]
keywords: Option<Vec<String>>,
}
fn map_crate(raw: CratesIoCrate) -> Option<DiscoveredPlugin> {
let CratesIoCrate {
name,
description,
repository,
homepage,
max_stable_version,
keywords,
} = raw;
let Some(version) = max_stable_version else {
warn!(
target: "plugin_discovery::crates_io",
crate_name = %name,
"skipping — every version yanked (no max_stable_version)"
);
return None;
};
let owner = owner_from_repo(repository.as_deref()).unwrap_or_else(|| "unknown".to_string());
let install_params = PluginsInstallParams {
crate_name: name.clone(),
version: Some(version.clone()),
repo: repo_slug(repository.as_deref()),
source: InstallSource::Release,
force: false,
require_signature: false,
skip_signature_verify: false,
};
let install_cmd = format!("cargo install {name} --version {version}");
Some(DiscoveredPlugin {
name,
version: Some(version),
description,
owner,
sources: vec![PluginSource::CratesIo],
repo_url: repository,
homepage,
tags: keywords.unwrap_or_default(),
category: PluginCategory::Unknown,
trust_tier: TrustTier::Unverified,
compat: CompatStatus::Unknown,
manifest_url: None,
install_cmd,
install_params,
})
}
fn owner_from_repo(url: Option<&str>) -> Option<String> {
let u = url?;
let prefix = "https://github.com/";
let rest = u.strip_prefix(prefix)?;
let org = rest.split('/').next()?;
if org.is_empty() {
None
} else {
Some(org.to_string())
}
}
fn repo_slug(url: Option<&str>) -> Option<String> {
let u = url?;
let prefix = "https://github.com/";
let rest = u.strip_prefix(prefix)?;
let mut parts = rest.split('/');
let org = parts.next()?;
let name = parts.next()?;
if org.is_empty() || name.is_empty() {
return None;
}
let name = name.trim_end_matches(".git");
Some(format!("{org}/{name}"))
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn page_body(items: &[(&str, &str, &str)]) -> serde_json::Value {
let crates: Vec<_> = items
.iter()
.map(|(name, version, repo)| {
serde_json::json!({
"name": name,
"description": "demo",
"repository": repo,
"homepage": null,
"max_stable_version": version,
"keywords": ["nexo", "demo"],
})
})
.collect();
serde_json::json!({ "crates": crates })
}
fn full_page_body(prefix: &str, version: &str, repo: &str) -> serde_json::Value {
let crates: Vec<_> = (0..100)
.map(|i| {
serde_json::json!({
"name": format!("{prefix}-{i}"),
"description": "demo",
"repository": repo,
"homepage": null,
"max_stable_version": version,
"keywords": [],
})
})
.collect();
serde_json::json!({ "crates": crates })
}
#[tokio::test]
async fn happy_path_single_page_returns_items() {
let server = MockServer::start().await;
let body = page_body(&[
(
"nexo-plugin-telegram",
"0.3.0",
"https://github.com/lordmacu/nexo-rs-plugin-telegram",
),
(
"nexo-plugin-whatsapp",
"0.1.3",
"https://github.com/lordmacu/nexo-rs-plugin-whatsapp",
),
]);
Mock::given(method("GET"))
.and(path("/api/v1/crates"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let src = CratesIoSource::new(server.uri(), Duration::from_secs(5));
let items = src.fetch().await.expect("fetch ok");
assert_eq!(items.len(), 4);
let tele = items
.iter()
.find(|p| p.name == "nexo-plugin-telegram")
.expect("telegram present");
assert_eq!(tele.owner, "lordmacu");
assert_eq!(tele.version.as_deref(), Some("0.3.0"));
assert_eq!(tele.sources, vec![PluginSource::CratesIo]);
assert_eq!(
tele.install_cmd,
"cargo install nexo-plugin-telegram --version 0.3.0"
);
assert_eq!(
tele.install_params.repo.as_deref(),
Some("lordmacu/nexo-rs-plugin-telegram")
);
}
#[tokio::test]
async fn yanked_only_crate_is_filtered_out() {
let server = MockServer::start().await;
let body = serde_json::json!({
"crates": [
{
"name": "nexo-plugin-broken",
"description": "all yanked",
"repository": "https://github.com/lordmacu/broken",
"max_stable_version": serde_json::Value::Null,
"keywords": []
}
]
});
Mock::given(method("GET"))
.and(path("/api/v1/crates"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let src = CratesIoSource::new(server.uri(), Duration::from_secs(5));
let items = src.fetch().await.expect("fetch ok");
assert!(
items.is_empty(),
"yanked-only crate must be filtered out, got {items:?}"
);
}
#[tokio::test]
async fn paginates_until_short_page() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/crates"))
.and(query_param("page", "1"))
.respond_with(ResponseTemplate::new(200).set_body_json(full_page_body(
"nexo-plugin",
"0.1.0",
"https://github.com/x/y",
)))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/crates"))
.and(query_param("page", "2"))
.respond_with(ResponseTemplate::new(200).set_body_json(page_body(&[(
"nexo-plugin-final",
"0.2.0",
"https://github.com/x/y",
)])))
.mount(&server)
.await;
let src = CratesIoSource::new(server.uri(), Duration::from_secs(5));
let items = src.fetch().await.expect("fetch ok");
assert_eq!(items.len(), 202);
assert!(items.iter().any(|p| p.name == "nexo-plugin-final"));
}
#[tokio::test]
async fn http_500_surfaces_as_source_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/crates"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let src = CratesIoSource::new(server.uri(), Duration::from_secs(5));
let err = src.fetch().await.expect_err("5xx must surface");
assert_eq!(err.source, SOURCE_NAME);
assert!(err.message.contains("status 500"), "{}", err.message);
}
#[test]
fn owner_from_repo_handles_common_shapes() {
assert_eq!(
owner_from_repo(Some("https://github.com/lordmacu/foo")),
Some("lordmacu".into())
);
assert_eq!(
owner_from_repo(Some("https://github.com/lordmacu/foo.git")),
Some("lordmacu".into())
);
assert_eq!(
owner_from_repo(Some("https://github.com/lordmacu/foo/tree/main")),
Some("lordmacu".into())
);
assert_eq!(owner_from_repo(Some("https://gitlab.com/foo/bar")), None);
assert_eq!(owner_from_repo(None), None);
}
#[test]
fn repo_slug_trims_git_suffix() {
assert_eq!(
repo_slug(Some("https://github.com/lordmacu/foo.git")).as_deref(),
Some("lordmacu/foo")
);
assert_eq!(
repo_slug(Some("https://github.com/lordmacu/foo/tree/main")).as_deref(),
Some("lordmacu/foo")
);
assert!(repo_slug(Some("https://github.com/")).is_none());
assert!(repo_slug(None).is_none());
}
}