use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::process::{Command, Stdio};
const HINTS_STATE_FILE: &str = "pack-update-hints.json";
const CHECK_INTERVAL_SECS: i64 = 86400;
const NO_PACK_UPDATE_HINTS_ENV: &str = "NONO_NO_PACK_UPDATE_HINTS";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PackHintEntry {
last_check: DateTime<Utc>,
installed_at_check: String,
latest: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct PackHintsState {
#[serde(default)]
entries: HashMap<String, PackHintEntry>,
}
pub fn show_pack_update_hints(profile_name: &str, silent: bool) {
if silent || is_opted_out() {
return;
}
let packs = collect_profile_packs(profile_name);
if packs.is_empty() {
return;
}
let cache_existed = state_file_path().is_some_and(|p| p.exists());
let mut state = load_state();
let now = Utc::now();
let mut hints: Vec<(String, String, String)> = Vec::new(); let mut stale: Vec<(String, String)> = Vec::new();
for (pack_ref, installed) in &packs {
match state.entries.get(pack_ref) {
Some(entry)
if now.signed_duration_since(entry.last_check).num_seconds()
< CHECK_INTERVAL_SECS =>
{
if let Some(ref latest) = entry.latest
&& is_newer(installed, latest)
{
hints.push((pack_ref.clone(), installed.clone(), latest.clone()));
}
}
_ => {
stale.push((pack_ref.clone(), installed.clone()));
}
}
}
if !stale.is_empty() {
if !cache_existed {
refresh_synchronous(&stale, &mut state);
save_state(&state);
for (pack_ref, installed) in &stale {
if let Some(entry) = state.entries.get(pack_ref)
&& let Some(ref latest) = entry.latest
&& is_newer(installed, latest)
{
hints.push((pack_ref.clone(), installed.clone(), latest.clone()));
}
}
} else {
refresh_in_background_process(&stale);
}
}
print_hints(&hints);
}
pub fn run_refresh_helper(args: crate::cli::PackUpdateHintHelperArgs) -> crate::Result<()> {
let Some(stale) = parse_refresh_helper_args(args.packs) else {
tracing::debug!("pack update hint helper received malformed pack/version arguments");
return Ok(());
};
if stale.is_empty() || is_opted_out() {
return Ok(());
}
let mut state = load_state();
refresh_synchronous(&stale, &mut state);
save_state(&state);
Ok(())
}
fn collect_profile_packs(profile_name: &str) -> Vec<(String, String)> {
let pack_map: HashMap<String, String> = crate::profile::list_pack_store_profiles()
.into_iter()
.collect();
let lockfile = match crate::package::read_lockfile() {
Ok(lf) => lf,
Err(_) => return Vec::new(),
};
let mut result: Vec<(String, String)> = Vec::new();
let mut seen_packs: HashSet<String> = HashSet::new();
let mut visited: HashSet<String> = HashSet::new();
let mut queue = vec![profile_name.to_string()];
while let Some(name) = queue.pop() {
if !visited.insert(name.clone()) {
continue;
}
if let Some(pack_ref) = pack_map.get(&name)
&& seen_packs.insert(pack_ref.clone())
&& let Some(locked) = lockfile.packages.get(pack_ref)
{
result.push((pack_ref.clone(), locked.version.clone()));
}
if let Some(bases) = crate::profile::load_profile_extends(&name) {
queue.extend(bases);
}
}
result
}
fn refresh_synchronous(packs: &[(String, String)], state: &mut PackHintsState) {
let registry_url = crate::registry_client::resolve_registry_url(None);
let client = crate::registry_client::RegistryClient::new(registry_url);
for (pack_ref, installed) in packs {
let pkg_ref = match crate::package::parse_package_ref(pack_ref) {
Ok(r) => r,
Err(_) => continue,
};
let latest = client
.fetch_package_status(&pkg_ref, Some(installed))
.ok()
.and_then(|s| s.latest);
state.entries.insert(
pack_ref.clone(),
PackHintEntry {
last_check: Utc::now(),
installed_at_check: installed.clone(),
latest,
},
);
}
}
fn refresh_in_background_process(stale: &[(String, String)]) {
let Ok(exe) = std::env::current_exe() else {
return;
};
let mut child = Command::new(exe);
child.arg("pack-update-hint-helper");
child.args(refresh_helper_args(stale));
child.stdin(Stdio::null());
child.stdout(Stdio::null());
child.stderr(Stdio::null());
let _ = child.spawn();
}
fn refresh_helper_args(stale: &[(String, String)]) -> Vec<String> {
stale
.iter()
.flat_map(|(pack_ref, installed)| [pack_ref.clone(), installed.clone()])
.collect()
}
fn parse_refresh_helper_args(args: Vec<String>) -> Option<Vec<(String, String)>> {
let mut chunks = args.chunks_exact(2);
let stale = chunks
.by_ref()
.map(|chunk| (chunk[0].clone(), chunk[1].clone()))
.collect();
if chunks.remainder().is_empty() {
Some(stale)
} else {
None
}
}
fn print_hints(hints: &[(String, String, String)]) {
if hints.is_empty() {
return;
}
let t = crate::theme::current();
for (pack_ref, installed, latest) in hints {
eprintln!(
" {} {} {} {} {}",
crate::theme::fg("update available", t.yellow),
crate::theme::fg(pack_ref, t.text),
crate::theme::fg(&format!("{installed} →"), t.subtext),
crate::theme::fg(latest, t.green),
crate::theme::fg(" run: nono update", t.subtext),
);
}
eprintln!();
}
fn state_file_path() -> Option<std::path::PathBuf> {
crate::package::nono_config_dir()
.ok()
.map(|d| d.join(HINTS_STATE_FILE))
}
fn load_state() -> PackHintsState {
let path = match state_file_path() {
Some(p) => p,
None => return PackHintsState::default(),
};
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_state(state: &PackHintsState) {
let path = match state_file_path() {
Some(p) => p,
None => return,
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string_pretty(state) {
let tmp_path =
path.with_file_name(format!(".{HINTS_STATE_FILE}.{}.tmp", std::process::id()));
let write_result = std::fs::write(&tmp_path, format!("{json}\n"))
.and_then(|()| std::fs::rename(&tmp_path, &path));
if write_result.is_err() {
let _ = std::fs::remove_file(&tmp_path);
}
}
}
fn is_opted_out() -> bool {
if std::env::var(NO_PACK_UPDATE_HINTS_ENV).is_ok() {
return true;
}
if std::env::var("NONO_NO_UPDATE_CHECK").is_ok() {
return true;
}
match crate::config::user::load_user_config() {
Ok(Some(config)) => !config.updates.check,
_ => false,
}
}
fn is_newer(installed: &str, latest: &str) -> bool {
let parse = |s: &str| -> Option<(u64, u64, u64)> {
let s = s.strip_prefix('v').unwrap_or(s);
let mut parts = s.splitn(4, '.');
let major: u64 = parts.next()?.parse().ok()?;
let minor: u64 = parts.next()?.parse().ok()?;
let patch: u64 = parts.next()?.parse().ok()?;
Some((major, minor, patch))
};
match (parse(installed), parse(latest)) {
(Some(i), Some(l)) => l > i,
(None, Some(_)) => true, _ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refresh_helper_args_round_trip() {
let stale = vec![
("always-further/claude".to_string(), "1.0.0".to_string()),
("always-further/codex".to_string(), "2.3.4".to_string()),
];
let args = refresh_helper_args(&stale);
assert_eq!(parse_refresh_helper_args(args), Some(stale));
}
#[test]
fn refresh_helper_args_reject_odd_values() {
assert!(parse_refresh_helper_args(vec!["always-further/claude".to_string()]).is_none());
}
#[test]
fn pack_hint_env_var_opts_out() {
let _lock = match crate::test_env::ENV_LOCK.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
let _env = crate::test_env::EnvVarGuard::set_all(&[(NO_PACK_UPDATE_HINTS_ENV, "1")]);
assert!(is_opted_out());
}
}