use anyhow::{Context, Result, bail};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use super::Recipe;
use super::fetch::{AGENT, INDEX, Index, REPO, TIMEOUT, cache_dir, unix_now};
const FETCH_WIDTH: usize = 8;
pub(super) fn try_refresh(root: &Path, cancel: &AtomicBool) -> Result<Index> {
let (tree_sha, paths) = list_recipe_paths()?;
if paths.is_empty() {
bail!("{REPO}@{tree_sha} lists no recipes/**/*.yaml");
}
let next = AtomicUsize::new(0);
let out: Mutex<Vec<(String, String)>> = Mutex::new(Vec::with_capacity(paths.len()));
let width = FETCH_WIDTH.min(paths.len());
std::thread::scope(|scope| {
for _ in 0..width {
scope.spawn(|| {
loop {
if cancel.load(Ordering::Relaxed) {
return;
}
let i = next.fetch_add(1, Ordering::Relaxed);
let Some(path) = paths.get(i) else { return };
let url = format!("https://raw.githubusercontent.com/{REPO}/{tree_sha}/{path}");
match get(&url) {
Ok(body) => out.lock().push((recipe_id(path), body)),
Err(e) => tracing::warn!("skipping recipe {path}: {e:#}"),
}
}
});
}
});
if cancel.load(Ordering::Relaxed) {
bail!("refresh cancelled");
}
if out.lock().is_empty() {
bail!(
"{REPO}@{tree_sha} listed {} recipe file(s) but none could be fetched",
paths.len()
);
}
let mut files: BTreeMap<String, String> = BTreeMap::new();
let mut recipes = Vec::new();
for (id, body) in out.into_inner() {
match Recipe::parse(id.clone(), &body) {
Ok(r) => recipes.push(r),
Err(e) => tracing::warn!("skipping recipe {id}: {e:#}"),
}
files.insert(id, body);
}
recipes.sort_by(|a, b| a.id.cmp(&b.id));
let fetched_at = unix_now();
let missing = paths.len().saturating_sub(files.len());
let incomplete = if missing > 0 {
Some(format!(
"{missing} of {} recipe file(s) could not be fetched, so the cache was left alone",
paths.len()
))
} else {
write_cache(root, &tree_sha, fetched_at, &files)
.err()
.map(|e| format!("the index could not be cached: {e:#}"))
};
Ok(Index {
recipes,
tree_sha,
fetched_at,
offline: None,
incomplete,
})
}
pub(super) fn recipe_id(path: &str) -> String {
path.trim_start_matches("recipes/")
.trim_end_matches(".yaml")
.to_string()
}
fn list_recipe_paths() -> Result<(String, Vec<String>)> {
let body = get(&format!(
"https://api.github.com/repos/{REPO}/git/trees/main?recursive=1"
))
.context("listing the recipe tree")?;
let doc: serde_json::Value =
serde_json::from_str(&body).context("tree response is not JSON")?;
let sha = doc
.get("sha")
.and_then(|s| s.as_str())
.context("tree response has no sha")?
.to_string();
if doc.get("truncated").and_then(|t| t.as_bool()) == Some(true) {
bail!("GitHub truncated the tree listing for {REPO}");
}
let entries = doc
.get("tree")
.and_then(|t| t.as_array())
.context("tree response has no tree")?;
let mut paths: Vec<String> = entries
.iter()
.filter(|e| e.get("type").and_then(|t| t.as_str()) == Some("blob"))
.filter_map(|e| e.get("path").and_then(|p| p.as_str()))
.filter(|p| p.starts_with("recipes/") && p.ends_with(".yaml"))
.map(str::to_string)
.collect();
paths.sort();
Ok((sha, paths))
}
fn agent() -> &'static ureq::Agent {
static AGENT_POOL: std::sync::OnceLock<ureq::Agent> = std::sync::OnceLock::new();
AGENT_POOL.get_or_init(|| {
ureq::Agent::config_builder()
.timeout_global(Some(TIMEOUT))
.build()
.into()
})
}
fn get(url: &str) -> Result<String> {
let response = agent()
.get(url)
.header("User-Agent", AGENT)
.call()
.with_context(|| format!("GET {url}"))?;
Ok(response.into_body().read_to_string()?)
}
pub(super) fn write_cache(
root: &Path,
tree_sha: &str,
fetched_at: u64,
files: &BTreeMap<String, String>,
) -> Result<()> {
let dir = cache_dir(root);
std::fs::create_dir_all(&dir)?;
let doc = serde_json::json!({
"tree_sha": tree_sha,
"fetched_at": fetched_at,
"files": files,
});
let tmp = dir.join("index.json.tmp");
std::fs::write(&tmp, serde_json::to_vec_pretty(&doc)?)?;
std::fs::rename(&tmp, dir.join(INDEX))?;
Ok(())
}
pub(super) fn commit_date(id: &str) -> Result<String> {
let body = get(&format!(
"https://api.github.com/repos/{REPO}/commits?path=recipes/{id}.yaml&per_page=1"
))
.with_context(|| format!("dating recipe {id}"))?;
let doc: serde_json::Value =
serde_json::from_str(&body).context("commits response is not JSON")?;
let date = doc
.get(0)
.and_then(|c| c.get("commit"))
.and_then(|c| c.get("committer"))
.and_then(|c| c.get("date"))
.and_then(|d| d.as_str())
.with_context(|| format!("no commit found for recipe {id}"))?;
Ok(date.split('T').next().unwrap_or(date).to_string())
}