use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::Recipe;
use super::fetch_github::{self, try_refresh};
pub(super) const REPO: &str = "Metrale/metralectl";
pub(super) const CACHE: &str = "metrale-recipes";
pub(super) const INDEX: &str = "index.json";
pub(super) const AGENT: &str = crate::identity::USER_AGENT;
pub(super) const TIMEOUT: Duration = Duration::from_secs(20);
#[derive(Clone, Debug, Default)]
pub struct Index {
pub recipes: Vec<Recipe>,
pub tree_sha: String,
pub fetched_at: u64,
pub offline: Option<String>,
pub incomplete: Option<String>,
}
impl Index {
pub fn age_text(&self) -> Option<String> {
if self.fetched_at == 0 {
return Some("never fetched".into());
}
let now = unix_now();
let secs = now.saturating_sub(self.fetched_at);
Some(match secs {
0..=3599 => format!("{} m old", secs / 60),
3600..=86399 => format!("{} h old", secs / 3600),
_ => format!("{} d old", secs / 86400),
})
}
pub fn status_text(&self) -> String {
match (&self.offline, self.age_text()) {
(Some(_), Some(age)) => format!("⚠ {age} — offline"),
(None, Some(age)) => age,
(Some(e), None) => format!("⚠ offline — {e}"),
(None, None) => "up to date".into(),
}
}
pub fn offline_detail(&self) -> Option<String> {
let raw = self.offline.as_ref()?;
let lowered = raw.to_lowercase();
let hint = if lowered.contains("dns")
|| lowered.contains("resolve")
|| lowered.contains("unreachable")
|| lowered.contains("no route")
{
"This machine has no route to github.com. Set HTTPS_PROXY to a host \
that does — recipes are then fetched through it — or copy the \
cached index (~/.metrale/metrale-recipes/index.json) from a machine \
that can reach it."
} else if lowered.contains("403") || lowered.contains("rate") {
"GitHub is rate-limiting this IP. The listing costs one API call per \
refresh; the cached recipes below are still usable."
} else if lowered.contains("timed out") || lowered.contains("timeout") {
"The request timed out. A slow or filtered link will do this; the \
cached recipes below are still usable."
} else {
"The cached recipes below are still usable."
};
Some(format!("{raw}. {hint}"))
}
}
pub(super) fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub(crate) fn cache_dir(root: &Path) -> PathBuf {
root.join(CACHE)
}
pub fn cached(root: &Path) -> Index {
let path = cache_dir(root).join(INDEX);
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Index::default(),
Err(e) => {
return Index {
offline: Some(format!("{} could not be read: {e}", path.display())),
..Index::default()
};
}
};
match parse_cache(&text) {
Ok(index) => index,
Err(e) => Index {
offline: Some(format!("cached index unreadable: {e}")),
..Index::default()
},
}
}
pub(crate) fn parse_cache(text: &str) -> Result<Index> {
let doc: serde_json::Value = serde_json::from_str(text)?;
let files = doc
.get("files")
.and_then(|f| f.as_object())
.context("no `files` object")?;
let mut recipes = Vec::new();
for (id, content) in files {
let Some(body) = content.as_str() else {
bail!("{id} is not text");
};
match Recipe::parse(id.clone(), body) {
Ok(r) => recipes.push(r),
Err(e) => tracing::warn!("skipping cached recipe {id}: {e:#}"),
}
}
recipes.sort_by(|a, b| a.id.cmp(&b.id));
Ok(Index {
recipes,
tree_sha: doc
.get("tree_sha")
.and_then(|s| s.as_str())
.unwrap_or_default()
.to_string(),
fetched_at: doc.get("fetched_at").and_then(|s| s.as_u64()).unwrap_or(0),
offline: None,
incomplete: None,
})
}
pub fn refresh(root: &Path, cancel: &AtomicBool) -> Index {
refresh_with(root, || try_refresh(root, cancel))
}
fn refresh_with(root: &Path, fetch: impl FnOnce() -> Result<Index>) -> Index {
match fetch() {
Ok(index) => index,
Err(e) => {
let mut fallback = cached(root);
fallback.offline = Some(one_line(&format!("{e:#}")));
fallback
}
}
}
pub fn refresh_in_background(root: &Path) -> (std::sync::mpsc::Receiver<Index>, Arc<AtomicBool>) {
let owned = root.to_path_buf();
let cancel = Arc::new(AtomicBool::new(false));
let rx = crate::tui::worker::spawn(
"metrale-recipes",
{
let cancel = Arc::clone(&cancel);
move || refresh(&owned, &cancel)
},
|e| {
let mut index = cached(root);
index.offline = Some(format!("fetcher thread unavailable: {e}"));
index
},
);
(rx, cancel)
}
fn one_line(s: &str) -> String {
let flat = s.replace('\n', " ");
if flat.chars().count() <= 90 {
return flat;
}
flat.chars().take(90).collect::<String>() + "…"
}
#[cfg(test)]
#[path = "fetch_tests.rs"]
mod tests;
pub fn updated_in_background(id: &str) -> std::sync::mpsc::Receiver<(String, Option<String>)> {
let owned = id.to_string();
let fallback_id = id.to_string();
crate::tui::worker::spawn(
"metrale-recipe-date",
move || {
let date = fetch_github::commit_date(&owned)
.map_err(|e| {
tracing::debug!("could not date recipe {owned}: {e:#}");
})
.ok();
(owned, date)
},
|_| (fallback_id, None),
)
}