use std::collections::BTreeMap;
use crate::catalog::Maintenance;
use crate::catalog::tool_catalog::{PLUGINS, ROKIT_TOOLS, SYSTEM_APPS, ToolKind, VSCODE_EXTENSIONS};
use crate::catalog::wally_packages::PACKAGES;
#[derive(Debug, PartialEq, Eq)]
pub struct Tracked {
pub key: &'static str,
pub badge: Maintenance,
pub repo: String,
}
pub fn tracked_entries() -> Vec<Tracked> {
let mut by_repo: BTreeMap<String, Tracked> = BTreeMap::new();
for spec in PACKAGES {
if let Some(repo) = github_slug(spec.git_repo) {
by_repo.entry(repo.clone()).or_insert(Tracked {
key: spec.key,
badge: spec.maintenance,
repo,
});
}
}
for entry in SYSTEM_APPS
.iter()
.chain(ROKIT_TOOLS)
.chain(PLUGINS)
.chain(VSCODE_EXTENSIONS)
{
let slug = match entry.kind {
ToolKind::StudioPlugin { github_repo, .. } | ToolKind::BlenderAddon { github_repo } => {
github_slug(github_repo)
}
_ => None,
};
if let Some(repo) = slug {
by_repo.entry(repo.clone()).or_insert(Tracked {
key: entry.key,
badge: entry.maintenance,
repo,
});
}
}
by_repo.into_values().collect()
}
pub fn github_slug(reference: &str) -> Option<String> {
let rest = reference
.trim_end_matches('/')
.trim_end_matches(".git")
.strip_prefix("https://github.com/")
.or_else(|| reference.strip_prefix("http://github.com/"))
.or_else(|| reference.strip_prefix("git@github.com:"))
.unwrap_or(reference);
let mut parts = rest.split('/');
let owner = parts.next()?;
let repo = parts.next()?;
if parts.next().is_some() || owner.is_empty() || repo.is_empty() || owner.contains(':') {
return None;
}
Some(format!("{owner}/{}", repo.trim_end_matches(".git")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_github_url_reduces_to_owner_and_repo() {
assert_eq!(
github_slug("https://github.com/jsdotlua/react-lua").as_deref(),
Some("jsdotlua/react-lua")
);
assert_eq!(
github_slug("https://github.com/littensy/charm.git").as_deref(),
Some("littensy/charm")
);
assert_eq!(
github_slug("Roblox/roblox-blender-plugin").as_deref(),
Some("Roblox/roblox-blender-plugin")
);
}
#[test]
fn a_non_github_reference_is_skipped() {
assert_eq!(github_slug("https://wally.run/package/foo"), None);
assert_eq!(github_slug("Git.Git"), None);
assert_eq!(github_slug(""), None);
assert_eq!(github_slug("owner"), None);
}
#[test]
fn entries_sharing_a_repository_are_deduplicated() {
let tracked = tracked_entries();
let mut repos: Vec<&str> = tracked.iter().map(|t| t.repo.as_str()).collect();
let before = repos.len();
repos.sort_unstable();
repos.dedup();
assert_eq!(before, repos.len(), "duplicate repositories: {repos:?}");
assert!(before > 10, "expected most of the catalog to be tracked, got {before}");
}
#[test]
#[ignore = "queries the GitHub API once per catalogued repo; run in CI with GITHUB_TOKEN"]
fn badges_do_not_contradict_upstream() {
let tracked = tracked_entries();
let token = std::env::var("GITHUB_TOKEN").ok();
if token.is_none() {
eprintln!(
"note: no GITHUB_TOKEN - {} requests against a 60/hour \
unauthenticated limit may not complete",
tracked.len()
);
}
let mut archived = Vec::new();
let mut stale = Vec::new();
let mut unreachable = Vec::new();
for entry in &tracked {
let url = format!("https://api.github.com/repos/{}", entry.repo);
let mut request = ureq::get(&url)
.header("User-Agent", concat!("rproj/", env!("CARGO_PKG_VERSION")))
.header("Accept", "application/vnd.github+json");
if let Some(token) = &token {
request = request.header("Authorization", &format!("Bearer {token}"));
}
let body = match request.call() {
Ok(mut response) => response.body_mut().read_to_string().unwrap_or_default(),
Err(err) => {
unreachable.push(format!("{} ({}): {err}", entry.key, entry.repo));
continue;
}
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) else {
unreachable.push(format!("{} ({}): unparseable response", entry.key, entry.repo));
continue;
};
if json.get("archived").and_then(|v| v.as_bool()) == Some(true)
&& entry.badge != Maintenance::Legacy
{
archived.push(format!(
"{} ({}) is archived upstream but badged {:?}",
entry.key, entry.repo, entry.badge
));
}
if entry.badge == Maintenance::Active
&& let Some(pushed) = json.get("pushed_at").and_then(|v| v.as_str())
&& let Some(year) = pushed.get(..4).and_then(|y| y.parse::<i32>().ok())
&& year < 2025
{
stale.push(format!(
"{} ({}) badged Active, last push {pushed}",
entry.key, entry.repo
));
}
}
for line in &stale {
eprintln!(" review {line}");
}
for line in &unreachable {
eprintln!(" skipped {line}");
}
assert!(
archived.is_empty(),
"curated badges contradict upstream:\n {}",
archived.join("\n ")
);
}
}