use serde_derive::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use std::{fs, io};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EnvStats {
pub last_activated: i64,
pub activation_count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UsageStats {
pub envs: HashMap<String, EnvStats>,
}
fn stats_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join("enwiro").join("usage-stats.json"))
}
pub fn now_timestamp() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
pub fn load_stats(path: &Path) -> UsageStats {
fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn load_stats_default() -> UsageStats {
match stats_path() {
Some(path) => load_stats(&path),
None => UsageStats::default(),
}
}
fn save_stats(path: &Path, stats: &UsageStats) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("tmp");
fs::write(&tmp, serde_json::to_string(stats)?)?;
fs::rename(&tmp, path)?;
Ok(())
}
pub fn record_activation(env_name: &str) {
let Some(path) = stats_path() else { return };
record_activation_to(&path, env_name);
}
fn record_activation_to(path: &Path, env_name: &str) {
let mut stats = load_stats(path);
let entry = stats.envs.entry(env_name.to_string()).or_default();
entry.last_activated = now_timestamp();
entry.activation_count += 1;
if let Err(e) = save_stats(path, &stats) {
tracing::warn!(error = %e, "Could not save usage stats");
}
}
pub fn frecency_score(stats: &EnvStats, now: i64) -> f64 {
let age_secs = (now - stats.last_activated).max(0) as f64;
let multiplier = if age_secs < 3600.0 {
4.0
} else if age_secs < 86400.0 {
2.0
} else if age_secs < 604800.0 {
0.5
} else {
0.25
};
stats.activation_count as f64 * multiplier
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_record_and_load() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("stats.json");
record_activation_to(&path, "my-project");
let stats = load_stats(&path);
assert_eq!(stats.envs.len(), 1);
let entry = &stats.envs["my-project"];
assert_eq!(entry.activation_count, 1);
assert!(entry.last_activated > 0);
}
#[test]
fn test_record_increments_count() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("stats.json");
record_activation_to(&path, "my-project");
record_activation_to(&path, "my-project");
let stats = load_stats(&path);
assert_eq!(stats.envs["my-project"].activation_count, 2);
}
#[test]
fn test_record_multiple_environments() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("stats.json");
record_activation_to(&path, "project-a");
record_activation_to(&path, "project-b");
record_activation_to(&path, "project-a");
let stats = load_stats(&path);
assert_eq!(stats.envs["project-a"].activation_count, 2);
assert_eq!(stats.envs["project-b"].activation_count, 1);
}
#[test]
fn test_frecency_score_recent_high() {
let now = 1_000_000;
let stats = EnvStats {
last_activated: now,
activation_count: 10,
};
assert!((frecency_score(&stats, now) - 40.0).abs() < 0.01);
}
#[test]
fn test_frecency_score_old_low() {
let now = 1_000_000;
let stats = EnvStats {
last_activated: now - 604801, activation_count: 10,
};
assert!((frecency_score(&stats, now) - 2.5).abs() < 0.01);
}
#[test]
fn test_frecency_score_bucket_boundaries() {
let now = 1_000_000;
let count = 10;
let stats = EnvStats {
last_activated: now - 3599,
activation_count: count,
};
assert!((frecency_score(&stats, now) - 40.0).abs() < 0.01);
let stats = EnvStats {
last_activated: now - 3600,
activation_count: count,
};
assert!((frecency_score(&stats, now) - 20.0).abs() < 0.01);
let stats = EnvStats {
last_activated: now - 86399,
activation_count: count,
};
assert!((frecency_score(&stats, now) - 20.0).abs() < 0.01);
let stats = EnvStats {
last_activated: now - 86400,
activation_count: count,
};
assert!((frecency_score(&stats, now) - 5.0).abs() < 0.01);
let stats = EnvStats {
last_activated: now - 604799,
activation_count: count,
};
assert!((frecency_score(&stats, now) - 5.0).abs() < 0.01);
let stats = EnvStats {
last_activated: now - 604800,
activation_count: count,
};
assert!((frecency_score(&stats, now) - 2.5).abs() < 0.01);
}
#[test]
fn test_load_missing_file_returns_empty() {
let stats = load_stats(Path::new("/nonexistent/path/stats.json"));
assert!(stats.envs.is_empty());
}
#[test]
fn test_load_corrupt_file_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("stats.json");
fs::write(&path, "not valid json{{{").unwrap();
let stats = load_stats(&path);
assert!(stats.envs.is_empty());
}
}