use std::path::Path;
use std::time::{Duration, SystemTime};
use serde::Deserialize;
pub const DEFAULT_URL: &str =
"https://orangex4.github.io/typst-universe-with-stars/packages.json";
const CACHE_FILENAME: &str = "typst-universe.json";
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct UniversePackage {
#[serde(default)]
pub name: String,
#[serde(default)]
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default, deserialize_with = "de_lenient_u64")]
pub stars: u64,
}
impl UniversePackage {
pub fn spec(&self) -> String {
if self.version.trim().is_empty() {
format!("@preview/{}", self.name.trim())
} else {
format!("@preview/{}:{}", self.name.trim(), self.version.trim())
}
}
}
fn de_lenient_u64<'de, D>(d: D) -> Result<u64, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = serde_json::Value::deserialize(d)?;
Ok(match v {
serde_json::Value::Number(n) => n
.as_u64()
.or_else(|| n.as_f64().map(|f| f.max(0.0) as u64))
.unwrap_or(0),
serde_json::Value::String(s) => s.trim().parse::<u64>().unwrap_or(0),
_ => 0,
})
}
pub fn parse_packages(bytes: &[u8]) -> Result<Vec<UniversePackage>, String> {
if let Ok(v) = serde_json::from_slice::<Vec<UniversePackage>>(bytes) {
return Ok(v);
}
#[derive(Deserialize)]
struct Wrapper {
#[serde(default)]
packages: Vec<UniversePackage>,
}
if let Ok(w) = serde_json::from_slice::<Wrapper>(bytes) {
if !w.packages.is_empty() {
return Ok(w.packages);
}
}
#[derive(Deserialize)]
struct Body {
#[serde(default)]
version: String,
#[serde(default)]
description: String,
#[serde(default, deserialize_with = "de_lenient_u64")]
stars: u64,
}
match serde_json::from_slice::<std::collections::BTreeMap<String, Body>>(bytes) {
Ok(m) => Ok(m
.into_iter()
.map(|(name, b)| UniversePackage {
name,
version: b.version,
description: b.description,
stars: b.stars,
})
.collect()),
Err(e) => Err(format!("unrecognized packages.json shape: {e}")),
}
}
fn version_key(v: &str) -> Vec<u64> {
v.split('.')
.map(|part| {
part.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse::<u64>()
.unwrap_or(0)
})
.collect()
}
pub fn latest_per_package(pkgs: Vec<UniversePackage>) -> Vec<UniversePackage> {
use std::collections::HashMap;
let mut best: HashMap<String, UniversePackage> = HashMap::new();
for p in pkgs {
let name = p.name.trim().to_string();
if name.is_empty() {
continue;
}
match best.get(&name) {
Some(existing) if version_key(&existing.version) >= version_key(&p.version) => {}
_ => {
best.insert(name, p);
}
}
}
let mut out: Vec<UniversePackage> = best.into_values().collect();
out.sort_by(|a, b| {
b.stars
.cmp(&a.stars)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
out
}
fn is_cache_fresh(path: &Path, ttl: Duration, now: SystemTime) -> bool {
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
let Ok(mtime) = meta.modified() else {
return false;
};
now.duration_since(mtime)
.map(|age| age < ttl)
.unwrap_or(false)
}
pub fn load(
cache_dir: &Path,
url: &str,
ttl: Duration,
fetch: impl Fn(&str) -> Result<Vec<u8>, String>,
) -> Result<Vec<UniversePackage>, String> {
load_with_clock(cache_dir, url, ttl, SystemTime::now(), false, fetch)
}
pub fn load_forced(
cache_dir: &Path,
url: &str,
fetch: impl Fn(&str) -> Result<Vec<u8>, String>,
) -> Result<Vec<UniversePackage>, String> {
load_with_clock(cache_dir, url, Duration::from_secs(0), SystemTime::now(), true, fetch)
}
pub fn load_with_clock(
cache_dir: &Path,
url: &str,
ttl: Duration,
now: SystemTime,
force: bool,
fetch: impl Fn(&str) -> Result<Vec<u8>, String>,
) -> Result<Vec<UniversePackage>, String> {
let cache_path = cache_dir.join(CACHE_FILENAME);
if !force && is_cache_fresh(&cache_path, ttl, now) {
if let Ok(bytes) = std::fs::read(&cache_path) {
if let Ok(pkgs) = parse_packages(&bytes) {
return Ok(latest_per_package(pkgs));
}
}
}
match fetch(url) {
Ok(bytes) => {
let pkgs = parse_packages(&bytes)?;
let _ = std::fs::create_dir_all(cache_dir);
let _ = crate::io_atomic::write(&cache_path, &bytes);
Ok(latest_per_package(pkgs))
}
Err(net_err) => {
if cache_path.exists() {
let bytes = std::fs::read(&cache_path)
.map_err(|e| format!("read stale cache: {e}"))?;
let pkgs = parse_packages(&bytes)?;
tracing::warn!("typst-universe refresh failed ({net_err}); using stale cache");
Ok(latest_per_package(pkgs))
} else {
Err(format!("fetch typst universe: {net_err}"))
}
}
}
}
pub fn reqwest_fetch(url: &str) -> Result<Vec<u8>, String> {
let url = url.to_string();
std::thread::scope(|s| {
s.spawn(move || -> Result<Vec<u8>, String> {
let rt = tokio::runtime::Runtime::new().map_err(|e| format!("tokio: {e}"))?;
rt.block_on(async {
let client = reqwest::Client::builder()
.user_agent("inkhaven")
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| format!("http client: {e}"))?;
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("GET {url}: {e}"))?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
let bytes = resp.bytes().await.map_err(|e| format!("read body: {e}"))?;
Ok(bytes.to_vec())
})
})
.join()
.map_err(|_| "fetch thread panicked".to_string())?
})
}
#[cfg(test)]
mod tests {
use super::*;
const ARRAY_JSON: &[u8] = br#"[
{"name":"cetz","version":"0.2.0","description":"draw","stars":900},
{"name":"cetz","version":"0.3.0","description":"draw","stars":900},
{"name":"fletcher","version":"0.5.1","description":"diagrams","stars":300},
{"name":"","version":"1.0.0","description":"nameless"}
]"#;
#[test]
fn parses_array_and_keeps_latest_version() {
let pkgs = latest_per_package(parse_packages(ARRAY_JSON).unwrap());
assert_eq!(pkgs.len(), 2);
let cetz = pkgs.iter().find(|p| p.name == "cetz").unwrap();
assert_eq!(cetz.version, "0.3.0");
assert_eq!(pkgs[0].name, "cetz");
}
#[test]
fn spec_is_well_formed_with_and_without_version() {
let p = UniversePackage {
name: "cetz".into(),
version: "0.3.0".into(),
description: String::new(),
stars: 0,
};
assert_eq!(p.spec(), "@preview/cetz:0.3.0");
let no_ver = UniversePackage {
name: "cetz".into(),
version: String::new(),
description: String::new(),
stars: 0,
};
assert_eq!(no_ver.spec(), "@preview/cetz");
}
#[test]
fn parses_wrapper_and_map_shapes() {
let wrapper = br#"{"packages":[{"name":"unify","version":"0.6.0"}]}"#;
assert_eq!(parse_packages(wrapper).unwrap()[0].name, "unify");
let map = br#"{"unify":{"version":"0.6.0","stars":"42"}}"#;
let m = parse_packages(map).unwrap();
assert_eq!(m[0].name, "unify");
assert_eq!(m[0].stars, 42); }
#[test]
fn lenient_stars_accepts_number_string_and_null() {
let json = br#"[
{"name":"a","stars":5},
{"name":"b","stars":"7"},
{"name":"c","stars":null},
{"name":"d"}
]"#;
let pkgs = parse_packages(json).unwrap();
let get = |n: &str| pkgs.iter().find(|p| p.name == n).unwrap().stars;
assert_eq!(get("a"), 5);
assert_eq!(get("b"), 7);
assert_eq!(get("c"), 0);
assert_eq!(get("d"), 0);
}
#[test]
fn load_prefers_fresh_cache_over_fetch() {
let dir = std::env::temp_dir().join(format!("ink-univ-test-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let cache = dir.join(CACHE_FILENAME);
std::fs::write(&cache, ARRAY_JSON).unwrap();
let pkgs = load_with_clock(&dir, "http://unused", Duration::from_secs(3600), SystemTime::now(), false, |_| {
panic!("fetch should not be called for a fresh cache")
})
.unwrap();
assert!(pkgs.iter().any(|p| p.name == "cetz"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_falls_back_to_stale_cache_on_fetch_error() {
let dir = std::env::temp_dir().join(format!("ink-univ-stale-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join(CACHE_FILENAME), ARRAY_JSON).unwrap();
let pkgs = load_with_clock(&dir, "http://unused", Duration::from_secs(0), SystemTime::now(), false, |_| {
Err("network down".into())
})
.unwrap();
assert!(pkgs.iter().any(|p| p.name == "fletcher"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn force_refetches_even_when_cache_is_fresh() {
let dir = std::env::temp_dir().join(format!("ink-univ-force-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join(CACHE_FILENAME), ARRAY_JSON).unwrap();
let fresh = br#"[{"name":"newpkg","version":"1.0.0"}]"#;
let pkgs = load_with_clock(
&dir,
"http://unused",
Duration::from_secs(3600),
SystemTime::now(),
true,
|_| Ok(fresh.to_vec()),
)
.unwrap();
assert!(pkgs.iter().any(|p| p.name == "newpkg"));
assert!(!pkgs.iter().any(|p| p.name == "cetz"));
let _ = std::fs::remove_dir_all(&dir);
}
}