use super::{CratesIoClient, Error};
pub enum ChangelogResult {
Found { filename: String, content: String },
NoRepository,
NotGitHub { url: String },
NotFound,
}
const CHANGELOG_FILENAMES: &[&str] = &[
"CHANGELOG.md",
"CHANGES.md",
"HISTORY.md",
"RELEASES.md",
"changelog.md",
"changes.md",
"history.md",
"releases.md",
];
impl CratesIoClient {
pub async fn fetch_changelog(&self, name: &str) -> Result<ChangelogResult, Error> {
let crate_resp = self.get_crate(name).await?;
let repo_url = match crate_resp.crate_data.repository {
Some(url) if !url.is_empty() => url,
_ => return Ok(ChangelogResult::NoRepository),
};
let (owner, repo) = match parse_github_repo(&repo_url) {
Some(pair) => pair,
None => return Ok(ChangelogResult::NotGitHub { url: repo_url }),
};
for filename in CHANGELOG_FILENAMES {
let url = format!(
"{}/{}/{}/HEAD/{}",
self.github_raw_base_url, owner, repo, filename
);
let resp = self.http.get(&url).send().await?;
if resp.status().is_success() {
let content = resp.text().await?;
return Ok(ChangelogResult::Found {
filename: filename.to_string(),
content,
});
}
}
Ok(ChangelogResult::NotFound)
}
}
fn parse_github_repo(url: &str) -> Option<(String, String)> {
let rest = url
.trim_end_matches('/')
.trim_end_matches(".git")
.strip_prefix("https://github.com/")
.or_else(|| url.strip_prefix("http://github.com/"))?;
let mut parts = rest.splitn(3, '/');
let owner = parts.next()?.to_string();
let repo = parts.next()?.to_string();
if owner.is_empty() || repo.is_empty() {
return None;
}
Some((owner, repo))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::CratesIoClient;
use std::time::Duration;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn crate_json(name: &str, repository: Option<&str>) -> serde_json::Value {
let mut c = serde_json::json!({
"name": name,
"max_version": "1.0.0",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
});
if let Some(r) = repository {
c["repository"] = serde_json::json!(r);
}
serde_json::json!({ "crate": c, "versions": [] })
}
#[tokio::test]
async fn fetch_changelog_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/crates/mycrate"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(crate_json("mycrate", Some("https://github.com/owner/repo"))),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/owner/repo/HEAD/CHANGELOG.md"))
.respond_with(
ResponseTemplate::new(200).set_body_string("# Changelog\n\n## 1.0.0\n- initial"),
)
.mount(&server)
.await;
let client = CratesIoClient::with_base_url(
"test",
Duration::from_millis(0),
Duration::from_secs(5),
&server.uri(),
)
.unwrap()
.with_github_raw_url(&server.uri());
match client.fetch_changelog("mycrate").await.unwrap() {
ChangelogResult::Found { filename, content } => {
assert_eq!(filename, "CHANGELOG.md");
assert!(content.contains("# Changelog"));
}
_ => panic!("expected ChangelogResult::Found"),
}
}
#[tokio::test]
async fn fetch_changelog_no_repository() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/crates/norepo"))
.respond_with(ResponseTemplate::new(200).set_body_json(crate_json("norepo", None)))
.mount(&server)
.await;
let client = CratesIoClient::with_base_url(
"test",
Duration::from_millis(0),
Duration::from_secs(5),
&server.uri(),
)
.unwrap();
assert!(matches!(
client.fetch_changelog("norepo").await.unwrap(),
ChangelogResult::NoRepository
));
}
#[test]
fn parse_github_repo_standard() {
let (owner, repo) = parse_github_repo("https://github.com/serde-rs/serde").unwrap();
assert_eq!(owner, "serde-rs");
assert_eq!(repo, "serde");
}
#[test]
fn parse_github_repo_trailing_slash() {
let (owner, repo) = parse_github_repo("https://github.com/tokio-rs/tokio/").unwrap();
assert_eq!(owner, "tokio-rs");
assert_eq!(repo, "tokio");
}
#[test]
fn parse_github_repo_git_suffix() {
let (owner, repo) = parse_github_repo("https://github.com/dtolnay/anyhow.git").unwrap();
assert_eq!(owner, "dtolnay");
assert_eq!(repo, "anyhow");
}
#[test]
fn parse_github_repo_with_subpath() {
let (owner, repo) =
parse_github_repo("https://github.com/rust-lang/rust/tree/master/library/std").unwrap();
assert_eq!(owner, "rust-lang");
assert_eq!(repo, "rust");
}
#[test]
fn parse_github_repo_non_github() {
assert!(parse_github_repo("https://gitlab.com/owner/repo").is_none());
assert!(parse_github_repo("https://bitbucket.org/owner/repo").is_none());
assert!(parse_github_repo("").is_none());
}
#[test]
fn parse_github_repo_incomplete() {
assert!(parse_github_repo("https://github.com/").is_none());
assert!(parse_github_repo("https://github.com/owner").is_none());
}
}