use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, SystemTime};
const TAP_FORMULA: &str = "deeplabua/tap/deepshrink";
const THROTTLE: Duration = Duration::from_secs(24 * 60 * 60);
pub fn upgrade_hint(quiet: bool, json: bool) -> Option<String> {
if quiet
|| json
|| std::env::var_os("DEEPSHRINK_NO_UPDATE_CHECK").is_some()
|| std::env::var_os("CI").is_some()
|| !std::io::stderr().is_terminal()
|| !is_homebrew_install()
{
return None;
}
let cache = cache_path()?;
if is_stale(&cache) {
spawn_refresh(&cache);
}
parse_hint(&std::fs::read_to_string(&cache).ok()?)
}
fn is_homebrew_install() -> bool {
std::env::current_exe()
.and_then(|p| p.canonicalize())
.map(|p| is_cellar_path(&p.to_string_lossy()))
.unwrap_or(false)
}
fn is_cellar_path(path: &str) -> bool {
path.contains("/Cellar/deepshrink/")
}
fn cache_path() -> Option<PathBuf> {
let base = match std::env::var_os("XDG_CACHE_HOME").filter(|v| !v.is_empty()) {
Some(x) => PathBuf::from(x),
None => PathBuf::from(std::env::var_os("HOME").filter(|v| !v.is_empty())?).join(".cache"),
};
Some(base.join("deepshrink").join("brew-outdated.json"))
}
fn is_stale(cache: &Path) -> bool {
match std::fs::metadata(cache).and_then(|m| m.modified()) {
Ok(mtime) => SystemTime::now()
.duration_since(mtime)
.map(|age| age >= THROTTLE)
.unwrap_or(true),
Err(_) => true,
}
}
fn spawn_refresh(cache: &Path) {
let Some(dir) = cache.parent() else {
return;
};
if std::fs::create_dir_all(dir).is_err() {
return;
}
let tmp = dir.join(format!("brew-outdated.{}.tmp", std::process::id()));
let script = format!(
"HOMEBREW_NO_AUTO_UPDATE=1 brew outdated --json=v2 {formula} > {tmp} 2>/dev/null \
&& mv -f {tmp} {cache} || rm -f {tmp}",
formula = TAP_FORMULA,
tmp = shell_single_quote(&tmp),
cache = shell_single_quote(cache),
);
let _ = Command::new("sh")
.arg("-c")
.arg(script)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
fn shell_single_quote(p: &Path) -> String {
format!("'{}'", p.to_string_lossy().replace('\'', r"'\''"))
}
fn parse_hint(brew_json: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(brew_json).ok()?;
let formula = v.get("formulae")?.as_array()?.iter().find(|f| {
f.get("name")
.and_then(|n| n.as_str())
.is_some_and(|n| n.ends_with("deepshrink"))
})?;
let current = formula.get("current_version")?.as_str()?;
let installed = formula
.get("installed_versions")?
.as_array()?
.last()?
.as_str()?;
if installed == current {
return None;
}
Some(format!(
"A new version of deepshrink is available: {installed} → {current}\n\
Update: brew upgrade {TAP_FORMULA}",
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cellar_path_detection() {
assert!(is_cellar_path(
"/opt/homebrew/Cellar/deepshrink/0.3.0/bin/deepshrink"
));
assert!(is_cellar_path(
"/usr/local/Cellar/deepshrink/0.3.0/bin/deepshrink"
));
assert!(!is_cellar_path("/usr/local/bin/deepshrink"));
assert!(!is_cellar_path("/home/u/.cargo/bin/deepshrink"));
}
#[test]
fn parse_hint_when_outdated() {
let json = r#"{"formulae":[{"name":"deeplabua/tap/deepshrink",
"installed_versions":["0.2.1"],"current_version":"0.3.0"}],"casks":[]}"#;
let hint = parse_hint(json).expect("expected a hint");
assert!(hint.contains("0.2.1 → 0.3.0"), "got: {hint}");
assert!(hint.contains("brew upgrade deeplabua/tap/deepshrink"));
}
#[test]
fn no_hint_when_up_to_date() {
assert_eq!(parse_hint(r#"{"formulae":[],"casks":[]}"#), None);
let json = r#"{"formulae":[{"name":"deepshrink",
"installed_versions":["0.3.0"],"current_version":"0.3.0"}]}"#;
assert_eq!(parse_hint(json), None);
}
#[test]
fn no_hint_on_garbage() {
assert_eq!(parse_hint("not json"), None);
assert_eq!(parse_hint("{}"), None);
assert_eq!(parse_hint(r#"{"formulae":[]}"#), None);
}
}