codewhale_release/
check.rs1use std::path::{Path, PathBuf};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22use anyhow::{Context, Result};
23use serde::{Deserialize, Serialize};
24
25pub const UPDATE_CHECK_CACHE_FILE: &str = "update-check.json";
28
29pub const DEFAULT_CHECK_INTERVAL_HOURS: u64 = 1;
31
32const OPT_OUT_ENV: &[&str] = &["CODEWHALE_NO_UPDATE_CHECK", "NO_UPDATE_NOTIFIER"];
34
35const CI_ENV: &[&str] = &[
37 "CI",
38 "CONTINUOUS_INTEGRATION",
39 "GITHUB_ACTIONS",
40 "GITLAB_CI",
41 "BUILDKITE",
42 "CIRCLECI",
43 "JENKINS_URL",
44 "TEAMCITY_VERSION",
45 "TF_BUILD",
46];
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum SuppressionReason {
51 OptOut(&'static str),
53 ContinuousIntegration(&'static str),
55}
56
57impl SuppressionReason {
58 #[must_use]
60 pub fn variable(self) -> &'static str {
61 match self {
62 Self::OptOut(var) | Self::ContinuousIntegration(var) => var,
63 }
64 }
65}
66
67#[must_use]
73pub fn suppression_reason() -> Option<SuppressionReason> {
74 for var in OPT_OUT_ENV {
75 if env_flag_is_truthy(var) {
76 return Some(SuppressionReason::OptOut(var));
77 }
78 }
79 for var in CI_ENV {
80 if env_flag_is_truthy(var) {
81 return Some(SuppressionReason::ContinuousIntegration(var));
82 }
83 }
84 None
85}
86
87fn env_flag_is_truthy(var: &str) -> bool {
88 match std::env::var(var) {
89 Ok(value) => flag_value_is_truthy(&value),
90 Err(_) => false,
91 }
92}
93
94fn flag_value_is_truthy(value: &str) -> bool {
95 !matches!(
96 value.trim().to_ascii_lowercase().as_str(),
97 "" | "0" | "false" | "no" | "off"
98 )
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct UpdateCheckCache {
104 pub checked_at_unix: u64,
106 #[serde(default)]
109 pub latest_tag: Option<String>,
110}
111
112impl UpdateCheckCache {
113 #[must_use]
115 pub fn now(latest_tag: Option<String>) -> Self {
116 Self {
117 checked_at_unix: now_unix(),
118 latest_tag,
119 }
120 }
121
122 #[must_use]
128 pub fn load(path: &Path) -> Option<Self> {
129 let raw = std::fs::read_to_string(path).ok()?;
130 serde_json::from_str(&raw).ok()
131 }
132
133 #[must_use]
139 pub fn is_fresh(&self, now_unix: u64, interval_hours: u64) -> bool {
140 if self.checked_at_unix > now_unix {
141 return false;
142 }
143 let age = now_unix - self.checked_at_unix;
144 age < interval_hours.saturating_mul(3600)
145 }
146
147 pub fn store(&self, path: &Path) -> Result<()> {
150 if let Some(dir) = path.parent() {
151 std::fs::create_dir_all(dir)
152 .with_context(|| format!("failed to create {}", dir.display()))?;
153 }
154 let tmp = path.with_extension("json.tmp");
155 let body = serde_json::to_vec_pretty(self).context("failed to serialize update cache")?;
156 std::fs::write(&tmp, body).with_context(|| format!("failed to write {}", tmp.display()))?;
157 std::fs::rename(&tmp, path)
158 .with_context(|| format!("failed to install {}", path.display()))?;
159 Ok(())
160 }
161}
162
163#[must_use]
165pub fn cache_path_in(codewhale_home: &Path) -> PathBuf {
166 codewhale_home.join(UPDATE_CHECK_CACHE_FILE)
167}
168
169#[must_use]
171pub fn now_unix() -> u64 {
172 SystemTime::now()
173 .duration_since(UNIX_EPOCH)
174 .map(|d| d.as_secs())
175 .unwrap_or(0)
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn cache_is_fresh_inside_the_interval_and_stale_outside_it() {
184 let entry = UpdateCheckCache {
185 checked_at_unix: 1_000_000,
186 latest_tag: Some("v0.9.5".to_string()),
187 };
188 assert!(entry.is_fresh(1_000_000 + 3599, 1));
190 assert!(!entry.is_fresh(1_000_000 + 3601, 1));
192 assert!(!entry.is_fresh(1_000_000 + 3600, 1));
195 }
196
197 #[test]
198 fn a_zero_interval_always_refetches() {
199 let entry = UpdateCheckCache {
200 checked_at_unix: 1_000_000,
201 latest_tag: None,
202 };
203 assert!(!entry.is_fresh(1_000_000, 0));
204 }
205
206 #[test]
207 fn a_future_timestamp_is_stale_not_permanently_fresh() {
208 let entry = UpdateCheckCache {
209 checked_at_unix: 2_000_000,
210 latest_tag: Some("v9.9.9".to_string()),
211 };
212 assert!(!entry.is_fresh(1_000_000, 24));
213 }
214
215 #[test]
216 fn store_then_load_round_trips_and_survives_an_existing_file() {
217 let dir = tempfile::tempdir().expect("tempdir");
218 let path = cache_path_in(dir.path());
219 assert_eq!(path.file_name().unwrap(), UPDATE_CHECK_CACHE_FILE);
220
221 let first = UpdateCheckCache {
222 checked_at_unix: 42,
223 latest_tag: Some("v0.9.5".to_string()),
224 };
225 first.store(&path).expect("store");
226 assert_eq!(UpdateCheckCache::load(&path), Some(first));
227
228 let second = UpdateCheckCache {
230 checked_at_unix: 99,
231 latest_tag: None,
232 };
233 second.store(&path).expect("overwrite");
234 assert_eq!(UpdateCheckCache::load(&path), Some(second));
235 assert!(!path.with_extension("json.tmp").exists());
236 }
237
238 #[test]
239 fn store_creates_a_missing_home_directory() {
240 let dir = tempfile::tempdir().expect("tempdir");
241 let path = cache_path_in(&dir.path().join("nested").join("home"));
242 UpdateCheckCache::now(Some("v1.0.0".to_string()))
243 .store(&path)
244 .expect("store into a fresh directory");
245 assert!(path.exists());
246 }
247
248 #[test]
249 fn a_corrupt_or_absent_cache_reads_as_none() {
250 let dir = tempfile::tempdir().expect("tempdir");
251 let path = cache_path_in(dir.path());
252 assert_eq!(UpdateCheckCache::load(&path), None);
253 std::fs::write(&path, b"{ not json").expect("write junk");
254 assert_eq!(UpdateCheckCache::load(&path), None);
255 }
256
257 #[test]
258 fn falsey_flag_values_do_not_count_as_set() {
259 for value in ["", "0", "false", "FALSE", " no ", "off"] {
260 assert!(
261 !flag_value_is_truthy(value),
262 "{value:?} should not read as set"
263 );
264 }
265 for value in ["1", "true", "yes", "azure-pipelines"] {
266 assert!(flag_value_is_truthy(value), "{value:?} should read as set");
267 }
268 }
269
270 #[test]
271 fn suppression_reason_names_the_responsible_variable() {
272 assert_eq!(
273 SuppressionReason::OptOut("CODEWHALE_NO_UPDATE_CHECK").variable(),
274 "CODEWHALE_NO_UPDATE_CHECK"
275 );
276 assert_eq!(
277 SuppressionReason::ContinuousIntegration("CI").variable(),
278 "CI"
279 );
280 }
281}