enwiro 0.3.15

Simplify your workflow with dedicated project environments for each workspace in your window manager
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,
}

/// Per-environment usage statistics.
#[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)
}

/// Load stats from disk. Returns empty stats on any error (missing file, corrupt JSON, etc.).
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()
}

/// Load stats from the default XDG path. Returns empty stats if path unavailable.
pub fn load_stats_default() -> UsageStats {
    match stats_path() {
        Some(path) => load_stats(&path),
        None => UsageStats::default(),
    }
}

/// Save stats atomically (write tmp + rename).
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(())
}

/// Record that an environment was activated. Best-effort (errors logged, not propagated).
pub fn record_activation(env_name: &str) {
    let Some(path) = stats_path() else { return };
    record_activation_to(&path, env_name);
}

/// Record activation to a specific path (for testing).
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");
    }
}

/// Compute frecency score for an environment (zoxide-style bucket multiplier).
/// Pass the current timestamp (seconds since epoch) for deterministic results.
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, // >1 week
            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;

        // Just under 1 hour → ×4
        let stats = EnvStats {
            last_activated: now - 3599,
            activation_count: count,
        };
        assert!((frecency_score(&stats, now) - 40.0).abs() < 0.01);

        // Exactly 1 hour → ×2
        let stats = EnvStats {
            last_activated: now - 3600,
            activation_count: count,
        };
        assert!((frecency_score(&stats, now) - 20.0).abs() < 0.01);

        // Just under 1 day → ×2
        let stats = EnvStats {
            last_activated: now - 86399,
            activation_count: count,
        };
        assert!((frecency_score(&stats, now) - 20.0).abs() < 0.01);

        // Exactly 1 day → ×0.5
        let stats = EnvStats {
            last_activated: now - 86400,
            activation_count: count,
        };
        assert!((frecency_score(&stats, now) - 5.0).abs() < 0.01);

        // Just under 1 week → ×0.5
        let stats = EnvStats {
            last_activated: now - 604799,
            activation_count: count,
        };
        assert!((frecency_score(&stats, now) - 5.0).abs() < 0.01);

        // Exactly 1 week → ×0.25
        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());
    }
}