use std::io::IsTerminal;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use fallow_config::OutputFormat;
use semver::Version;
use serde::{Deserialize, Serialize};
use crate::api::{api_url, try_api_agent_with_timeout};
const CACHE_SCHEMA_VERSION: u8 = 1;
const CHECK_TTL_SECS: u64 = 24 * 60 * 60;
const FETCH_GRACE_MS: u64 = 200;
const FETCH_CONNECT_TIMEOUT_SECS: u64 = 1;
const FETCH_TOTAL_TIMEOUT_SECS: u64 = 1;
const LATEST_VERSION_PATH: &str = "/v1/cli/latest-version";
const UPDATE_CHECK_ENV: &str = "FALLOW_UPDATE_CHECK";
const DO_NOT_TRACK_ENV: &str = "DO_NOT_TRACK";
const TELEMETRY_DISABLED_ENV: &str = "FALLOW_TELEMETRY_DISABLED";
const CHANGELOG_URL: &str = "https://github.com/fallow-rs/fallow/blob/main/CHANGELOG.md";
#[derive(Clone, Debug, Deserialize, Serialize)]
struct UpdateCache {
schema_version: u8,
#[serde(default)]
disabled: bool,
#[serde(default)]
latest_version: String,
#[serde(default)]
checked_at_secs: u64,
}
impl Default for UpdateCache {
fn default() -> Self {
Self {
schema_version: CACHE_SCHEMA_VERSION,
disabled: false,
latest_version: String::new(),
checked_at_secs: 0,
}
}
}
#[derive(Debug, Deserialize)]
struct LatestVersionResponse {
latest: String,
}
#[derive(Clone, Copy, Debug)]
struct DisplayContext {
quiet: bool,
human: bool,
stdout_tty: bool,
stderr_tty: bool,
env_disabled: bool,
}
pub fn maybe_nudge(output: OutputFormat, quiet: bool, telemetry_note_printed: bool) {
if telemetry_note_printed {
return;
}
let ctx = DisplayContext {
quiet,
human: matches!(output, OutputFormat::Human),
stdout_tty: std::io::stdout().is_terminal(),
stderr_tty: std::io::stderr().is_terminal(),
env_disabled: env_disabled(),
};
if !should_run(ctx) {
return;
}
let Some(path) = cache_path() else {
return;
};
let cache = read_cache_from(&path).unwrap_or_default();
if cache.disabled {
return;
}
let current = env!("CARGO_PKG_VERSION");
if is_newer_stable(current, &cache.latest_version) {
eprintln!(
"A newer fallow is available ({}, you have {current}). Changelog: {CHANGELOG_URL} (silence: {UPDATE_CHECK_ENV}=off)",
cache.latest_version
);
}
if cache_is_expired(cache.checked_at_secs, now_secs(), CHECK_TTL_SECS) {
spawn_background_refresh(path, cache);
}
}
fn should_run(ctx: DisplayContext) -> bool {
!ctx.quiet && ctx.human && ctx.stdout_tty && ctx.stderr_tty && !ctx.env_disabled
}
fn is_newer_stable(current: &str, latest: &str) -> bool {
let (Ok(current), Ok(latest)) = (Version::parse(current), Version::parse(latest)) else {
return false;
};
current.pre.is_empty() && latest.pre.is_empty() && current < latest
}
fn cache_is_expired(checked_at_secs: u64, now: u64, ttl_secs: u64) -> bool {
now.saturating_sub(checked_at_secs) >= ttl_secs
}
fn env_disabled() -> bool {
env_truthy(DO_NOT_TRACK_ENV)
|| env_truthy(TELEMETRY_DISABLED_ENV)
|| update_check_off()
|| is_ci()
}
fn update_check_off() -> bool {
std::env::var(UPDATE_CHECK_ENV).ok().is_some_and(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"off" | "0" | "false" | "disabled" | "no"
)
})
}
fn env_truthy(name: &str) -> bool {
std::env::var(name).ok().is_some_and(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
}
fn is_ci() -> bool {
std::env::var_os("CI").is_some()
|| std::env::var_os("GITHUB_ACTIONS").is_some()
|| std::env::var_os("GITLAB_CI").is_some()
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn cache_path() -> Option<PathBuf> {
let base = if cfg!(windows) {
std::env::var_os("APPDATA").map(PathBuf::from)
} else if cfg!(target_os = "macos") {
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|home| home.join("Library").join("Application Support"))
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
}?;
Some(base.join("fallow").join("update-check.json"))
}
fn read_cache_from(path: &std::path::Path) -> Result<UpdateCache, String> {
let raw = std::fs::read_to_string(path).map_err(|err| err.to_string())?;
let cache: UpdateCache = serde_json::from_str(&raw).map_err(|err| err.to_string())?;
if cache.schema_version == CACHE_SCHEMA_VERSION {
return Ok(cache);
}
Ok(UpdateCache {
disabled: cache.disabled,
..UpdateCache::default()
})
}
fn write_cache_to(path: &std::path::Path, cache: &UpdateCache) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|err| err.to_string())?;
}
let mut raw = serde_json::to_string_pretty(cache).map_err(|err| err.to_string())?;
raw.push('\n');
std::fs::write(path, raw).map_err(|err| err.to_string())
}
fn spawn_background_refresh(path: PathBuf, prior: UpdateCache) {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(refresh_cache(&path, &prior));
});
let _ = rx.recv_timeout(Duration::from_millis(FETCH_GRACE_MS));
}
fn refresh_cache(path: &std::path::Path, prior: &UpdateCache) -> Result<(), String> {
let latest = fetch_latest_version()?;
let updated = UpdateCache {
schema_version: CACHE_SCHEMA_VERSION,
disabled: prior.disabled,
latest_version: latest,
checked_at_secs: now_secs(),
};
write_cache_to(path, &updated)
}
fn fetch_latest_version() -> Result<String, String> {
let agent = try_api_agent_with_timeout(FETCH_CONNECT_TIMEOUT_SECS, FETCH_TOTAL_TIMEOUT_SECS)
.map_err(|err| err.to_string())?;
let url = api_url(LATEST_VERSION_PATH);
let mut response = agent
.get(&url)
.header("Accept", "application/json")
.call()
.map_err(|err| err.to_string())?;
if !response.status().is_success() {
return Err(format!(
"latest-version endpoint returned {}",
response.status()
));
}
let body: LatestVersionResponse = response
.body_mut()
.read_json()
.map_err(|err| err.to_string())?;
if Version::parse(&body.latest).is_err() {
return Err(format!(
"latest-version payload is not semver: {}",
body.latest
));
}
Ok(body.latest)
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx(
quiet: bool,
human: bool,
stdout_tty: bool,
stderr_tty: bool,
env_disabled: bool,
) -> DisplayContext {
DisplayContext {
quiet,
human,
stdout_tty,
stderr_tty,
env_disabled,
}
}
#[test]
fn should_run_only_on_interactive_human_run() {
assert!(should_run(ctx(false, true, true, true, false)));
}
#[test]
fn quiet_suppresses() {
assert!(!should_run(ctx(true, true, true, true, false)));
}
#[test]
fn non_human_format_suppresses() {
assert!(!should_run(ctx(false, false, true, true, false)));
}
#[test]
fn non_tty_stdout_suppresses() {
assert!(!should_run(ctx(false, true, false, true, false)));
}
#[test]
fn non_tty_stderr_suppresses() {
assert!(!should_run(ctx(false, true, true, false, false)));
}
#[test]
fn env_disabled_suppresses() {
assert!(!should_run(ctx(false, true, true, true, true)));
}
#[test]
fn newer_stable_is_detected() {
assert!(is_newer_stable("2.85.0", "2.88.3"));
assert!(is_newer_stable("2.88.2", "2.88.3"));
assert!(is_newer_stable("1.0.0", "2.0.0"));
}
#[test]
fn equal_or_newer_current_is_not_nudged() {
assert!(!is_newer_stable("2.88.3", "2.88.3"));
assert!(!is_newer_stable("2.89.0", "2.88.3"));
}
#[test]
fn prerelease_either_side_is_not_nudged() {
assert!(!is_newer_stable("2.88.0-rc.1", "2.88.0"));
assert!(!is_newer_stable("2.85.0", "2.88.0-rc.1"));
assert!(!is_newer_stable("2.88.0-rc.1", "2.88.0-rc.2"));
}
#[test]
fn unparseable_version_is_not_nudged() {
assert!(!is_newer_stable("not-semver", "2.88.3"));
assert!(!is_newer_stable("2.85.0", ""));
assert!(!is_newer_stable("2.85.0", "garbage"));
}
#[test]
fn cache_expiry_respects_ttl() {
assert!(!cache_is_expired(
1000,
1000 + CHECK_TTL_SECS - 1,
CHECK_TTL_SECS
));
assert!(cache_is_expired(
1000,
1000 + CHECK_TTL_SECS,
CHECK_TTL_SECS
));
assert!(cache_is_expired(
1000,
1000 + CHECK_TTL_SECS * 2,
CHECK_TTL_SECS
));
assert!(cache_is_expired(0, now_secs(), CHECK_TTL_SECS));
}
#[test]
fn cache_round_trips_and_preserves_disabled() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("update-check.json");
let original = UpdateCache {
schema_version: CACHE_SCHEMA_VERSION,
disabled: true,
latest_version: "2.88.3".to_owned(),
checked_at_secs: 1234,
};
write_cache_to(&path, &original).unwrap();
let loaded = read_cache_from(&path).unwrap();
assert!(loaded.disabled);
assert_eq!(loaded.latest_version, "2.88.3");
assert_eq!(loaded.checked_at_secs, 1234);
}
#[test]
fn cache_schema_mismatch_discards_version_answer_but_preserves_disabled() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("update-check.json");
std::fs::write(
&path,
r#"{
"schema_version": 99,
"disabled": true,
"latest_version": "99.0.0",
"checked_at_secs": 9999999999
}"#,
)
.unwrap();
let loaded = read_cache_from(&path).unwrap();
assert_eq!(loaded.schema_version, CACHE_SCHEMA_VERSION);
assert!(loaded.disabled);
assert!(loaded.latest_version.is_empty());
assert_eq!(loaded.checked_at_secs, 0);
}
#[test]
fn refresh_preserves_disabled_flag() {
let prior = UpdateCache {
schema_version: CACHE_SCHEMA_VERSION,
disabled: true,
latest_version: "2.85.0".to_owned(),
checked_at_secs: 1,
};
let updated = UpdateCache {
schema_version: CACHE_SCHEMA_VERSION,
disabled: prior.disabled,
latest_version: "2.88.3".to_owned(),
checked_at_secs: now_secs(),
};
assert!(updated.disabled);
assert_eq!(updated.latest_version, "2.88.3");
}
#[test]
fn missing_cache_reads_as_error_not_panic() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist.json");
assert!(read_cache_from(&path).is_err());
assert!(!is_newer_stable(
env!("CARGO_PKG_VERSION"),
&UpdateCache::default().latest_version
));
}
}