use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommunityPlugin {
pub crate_name: String,
pub version: String,
pub summary: String,
}
pub const SEARCH_TIMEOUT: Duration = Duration::from_secs(5);
#[must_use]
pub fn search_url() -> String {
format!(
"https://crates.io/api/v1/crates?q={}&per_page=50",
super::catalog::COMMUNITY_PREFIX
)
}
fn user_agent() -> String {
format!(
"autumn-cli/{} (https://github.com/autumn-foundation/autumn)",
env!("CARGO_PKG_VERSION")
)
}
#[must_use]
pub fn parse_search_response(body: &str) -> Vec<CommunityPlugin> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
return Vec::new();
};
let Some(crates) = value.get("crates").and_then(serde_json::Value::as_array) else {
return Vec::new();
};
let mut found: Vec<CommunityPlugin> = crates
.iter()
.filter_map(|entry| {
let name = entry.get("name")?.as_str()?;
if !super::catalog::is_community_name(name) {
return None;
}
let version = pick_version(entry).unwrap_or_default();
Some(CommunityPlugin {
crate_name: name.to_owned(),
version,
summary: one_line(
entry
.get("description")
.and_then(serde_json::Value::as_str)
.unwrap_or_default(),
),
})
})
.collect();
found.sort_by(|a, b| a.crate_name.cmp(&b.crate_name));
found
}
fn pick_version(entry: &serde_json::Value) -> Option<String> {
["max_stable_version", "max_version", "newest_version"]
.into_iter()
.find_map(|field| entry.get(field).and_then(serde_json::Value::as_str))
.map(std::borrow::ToOwned::to_owned)
}
fn one_line(text: &str) -> String {
text.chars()
.map(|c| {
if super::index::is_unsafe_char(c) {
' '
} else {
c
}
})
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
#[must_use]
pub fn search() -> Option<Vec<CommunityPlugin>> {
let body = get(&search_url())?;
Some(parse_search_response(&body))
}
#[must_use]
pub fn latest_version(crate_name: &str) -> Option<String> {
let body = get(&format!("https://crates.io/api/v1/crates/{crate_name}"))?;
let value = serde_json::from_str::<serde_json::Value>(&body).ok()?;
pick_version(value.get("crate")?)
}
const MAX_RESPONSE_BYTES: u64 = 4 * 1024 * 1024;
fn get(url: &str) -> Option<String> {
use std::io::Read as _;
let client = reqwest::blocking::Client::builder()
.timeout(SEARCH_TIMEOUT)
.redirect(reqwest::redirect::Policy::limited(2))
.user_agent(user_agent())
.build()
.ok()?;
let response = client.get(url).send().ok()?;
if !response.status().is_success() {
return None;
}
let mut body = Vec::new();
response
.take(MAX_RESPONSE_BYTES)
.read_to_end(&mut body)
.ok()?;
String::from_utf8(body).ok()
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"{
"crates": [
{"name": "autumn-plugin-live-feed", "max_version": "0.3.1", "description": "Live feeds for autumn-web"},
{"name": "autumn-plugin-audit", "newest_version": "1.0.0", "description": "Audit trail"},
{"name": "autumn-web", "max_version": "0.7.0", "description": "The framework itself"},
{"name": "some-other-crate", "max_version": "2.0.0", "description": "unrelated"}
]
}"#;
#[test]
fn only_convention_named_crates_are_kept() {
let found = parse_search_response(SAMPLE);
let names: Vec<&str> = found.iter().map(|c| c.crate_name.as_str()).collect();
assert_eq!(names, ["autumn-plugin-audit", "autumn-plugin-live-feed"]);
}
#[test]
fn version_and_summary_come_from_the_response() {
let found = parse_search_response(SAMPLE);
let feed = found
.iter()
.find(|c| c.crate_name == "autumn-plugin-live-feed")
.expect("live-feed");
assert_eq!(feed.version, "0.3.1");
assert_eq!(feed.summary, "Live feeds for autumn-web");
}
#[test]
fn either_version_field_is_accepted() {
let found = parse_search_response(SAMPLE);
let audit = found
.iter()
.find(|c| c.crate_name == "autumn-plugin-audit")
.expect("audit");
assert_eq!(audit.version, "1.0.0");
}
#[test]
fn the_max_stable_version_wins_over_the_newest_publish() {
let body = r#"{"crates":[{"name":"autumn-plugin-x","max_stable_version":"2.0.0",
"max_version":"2.1.0-rc.1","newest_version":"1.4.3","description":"back-ported"}]}"#;
let found = parse_search_response(body);
assert_eq!(found[0].version, "2.0.0");
}
#[test]
fn control_characters_never_survive_into_the_summary() {
let body = "{\"crates\":[{\"name\":\"autumn-plugin-x\",\"max_version\":\"0.1.0\",\
\"description\":\"safe\\u001b[2J\\u0007evil\"}]}";
let found = parse_search_response(body);
assert_eq!(found.len(), 1);
assert!(
!found[0].summary.chars().any(char::is_control),
"{:?}",
found[0].summary
);
assert_eq!(found[0].summary, "safe [2J evil");
}
#[test]
fn a_missing_description_becomes_an_empty_summary() {
let body = r#"{"crates":[{"name":"autumn-plugin-x","max_version":"0.1.0"}]}"#;
let found = parse_search_response(body);
assert_eq!(found.len(), 1);
assert_eq!(found[0].summary, "");
}
#[test]
fn a_multi_line_description_is_flattened_to_one_line() {
let body = r#"{"crates":[{"name":"autumn-plugin-x","max_version":"0.1.0","description":"first\nsecond"}]}"#;
let found = parse_search_response(body);
assert_eq!(found[0].summary, "first second");
}
#[test]
fn a_garbage_body_yields_no_plugins() {
assert!(parse_search_response("not json").is_empty());
assert!(parse_search_response("{}").is_empty());
}
#[test]
fn the_search_url_asks_crates_io_for_the_documented_prefix() {
let url = search_url();
assert!(url.starts_with("https://crates.io/api/v1/crates"), "{url}");
assert!(url.contains("autumn-plugin-"), "{url}");
}
}