use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
pub const UPDATE_CHECK_CACHE_FILE: &str = "update-check.json";
pub const DEFAULT_CHECK_INTERVAL_HOURS: u64 = 1;
const OPT_OUT_ENV: &[&str] = &["CODEWHALE_NO_UPDATE_CHECK", "NO_UPDATE_NOTIFIER"];
const CI_ENV: &[&str] = &[
"CI",
"CONTINUOUS_INTEGRATION",
"GITHUB_ACTIONS",
"GITLAB_CI",
"BUILDKITE",
"CIRCLECI",
"JENKINS_URL",
"TEAMCITY_VERSION",
"TF_BUILD",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SuppressionReason {
OptOut(&'static str),
ContinuousIntegration(&'static str),
}
impl SuppressionReason {
#[must_use]
pub fn variable(self) -> &'static str {
match self {
Self::OptOut(var) | Self::ContinuousIntegration(var) => var,
}
}
}
#[must_use]
pub fn suppression_reason() -> Option<SuppressionReason> {
for var in OPT_OUT_ENV {
if env_flag_is_truthy(var) {
return Some(SuppressionReason::OptOut(var));
}
}
for var in CI_ENV {
if env_flag_is_truthy(var) {
return Some(SuppressionReason::ContinuousIntegration(var));
}
}
None
}
fn env_flag_is_truthy(var: &str) -> bool {
match std::env::var(var) {
Ok(value) => flag_value_is_truthy(&value),
Err(_) => false,
}
}
fn flag_value_is_truthy(value: &str) -> bool {
!matches!(
value.trim().to_ascii_lowercase().as_str(),
"" | "0" | "false" | "no" | "off"
)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpdateCheckCache {
pub checked_at_unix: u64,
#[serde(default)]
pub latest_tag: Option<String>,
}
impl UpdateCheckCache {
#[must_use]
pub fn now(latest_tag: Option<String>) -> Self {
Self {
checked_at_unix: now_unix(),
latest_tag,
}
}
#[must_use]
pub fn load(path: &Path) -> Option<Self> {
let raw = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&raw).ok()
}
#[must_use]
pub fn is_fresh(&self, now_unix: u64, interval_hours: u64) -> bool {
if self.checked_at_unix > now_unix {
return false;
}
let age = now_unix - self.checked_at_unix;
age < interval_hours.saturating_mul(3600)
}
pub fn store(&self, path: &Path) -> Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.with_context(|| format!("failed to create {}", dir.display()))?;
}
let tmp = path.with_extension("json.tmp");
let body = serde_json::to_vec_pretty(self).context("failed to serialize update cache")?;
std::fs::write(&tmp, body).with_context(|| format!("failed to write {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.with_context(|| format!("failed to install {}", path.display()))?;
Ok(())
}
}
#[must_use]
pub fn cache_path_in(codewhale_home: &Path) -> PathBuf {
codewhale_home.join(UPDATE_CHECK_CACHE_FILE)
}
#[must_use]
pub fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_is_fresh_inside_the_interval_and_stale_outside_it() {
let entry = UpdateCheckCache {
checked_at_unix: 1_000_000,
latest_tag: Some("v0.9.5".to_string()),
};
assert!(entry.is_fresh(1_000_000 + 3599, 1));
assert!(!entry.is_fresh(1_000_000 + 3601, 1));
assert!(!entry.is_fresh(1_000_000 + 3600, 1));
}
#[test]
fn a_zero_interval_always_refetches() {
let entry = UpdateCheckCache {
checked_at_unix: 1_000_000,
latest_tag: None,
};
assert!(!entry.is_fresh(1_000_000, 0));
}
#[test]
fn a_future_timestamp_is_stale_not_permanently_fresh() {
let entry = UpdateCheckCache {
checked_at_unix: 2_000_000,
latest_tag: Some("v9.9.9".to_string()),
};
assert!(!entry.is_fresh(1_000_000, 24));
}
#[test]
fn store_then_load_round_trips_and_survives_an_existing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = cache_path_in(dir.path());
assert_eq!(path.file_name().unwrap(), UPDATE_CHECK_CACHE_FILE);
let first = UpdateCheckCache {
checked_at_unix: 42,
latest_tag: Some("v0.9.5".to_string()),
};
first.store(&path).expect("store");
assert_eq!(UpdateCheckCache::load(&path), Some(first));
let second = UpdateCheckCache {
checked_at_unix: 99,
latest_tag: None,
};
second.store(&path).expect("overwrite");
assert_eq!(UpdateCheckCache::load(&path), Some(second));
assert!(!path.with_extension("json.tmp").exists());
}
#[test]
fn store_creates_a_missing_home_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let path = cache_path_in(&dir.path().join("nested").join("home"));
UpdateCheckCache::now(Some("v1.0.0".to_string()))
.store(&path)
.expect("store into a fresh directory");
assert!(path.exists());
}
#[test]
fn a_corrupt_or_absent_cache_reads_as_none() {
let dir = tempfile::tempdir().expect("tempdir");
let path = cache_path_in(dir.path());
assert_eq!(UpdateCheckCache::load(&path), None);
std::fs::write(&path, b"{ not json").expect("write junk");
assert_eq!(UpdateCheckCache::load(&path), None);
}
#[test]
fn falsey_flag_values_do_not_count_as_set() {
for value in ["", "0", "false", "FALSE", " no ", "off"] {
assert!(
!flag_value_is_truthy(value),
"{value:?} should not read as set"
);
}
for value in ["1", "true", "yes", "azure-pipelines"] {
assert!(flag_value_is_truthy(value), "{value:?} should read as set");
}
}
#[test]
fn suppression_reason_names_the_responsible_variable() {
assert_eq!(
SuppressionReason::OptOut("CODEWHALE_NO_UPDATE_CHECK").variable(),
"CODEWHALE_NO_UPDATE_CHECK"
);
assert_eq!(
SuppressionReason::ContinuousIntegration("CI").variable(),
"CI"
);
}
}