Skip to main content

ccboard_core/pricing/
cache.rs

1//! Pricing cache management
2//!
3//! Stores fetched pricing data in `~/.cache/ccboard/pricing.json` with TTL.
4
5use super::litellm::{CachedPricing, LITELLM_PRICING_URL};
6use crate::pricing::ModelPricing;
7use anyhow::{Context, Result};
8use std::collections::HashMap;
9use std::path::PathBuf;
10
11/// Cache expiration duration (7 days)
12const CACHE_TTL_DAYS: i64 = 7;
13
14/// Get cache file path
15pub fn cache_path() -> Result<PathBuf> {
16    let cache_dir = dirs::cache_dir()
17        .context("Could not determine cache directory")?
18        .join("ccboard");
19
20    std::fs::create_dir_all(&cache_dir)
21        .with_context(|| format!("Failed to create cache directory: {}", cache_dir.display()))?;
22
23    Ok(cache_dir.join("pricing.json"))
24}
25
26/// Load pricing from cache
27pub fn load_cached_pricing() -> Result<Option<HashMap<String, ModelPricing>>> {
28    let path = cache_path()?;
29
30    if !path.exists() {
31        tracing::debug!("No pricing cache found at {}", path.display());
32        return Ok(None);
33    }
34
35    let content = std::fs::read_to_string(&path)
36        .with_context(|| format!("Failed to read pricing cache: {}", path.display()))?;
37
38    let cached: CachedPricing = serde_json::from_str(&content)
39        .with_context(|| format!("Failed to parse pricing cache: {}", path.display()))?;
40
41    // Check if cache is expired
42    let now = chrono::Utc::now();
43    let age = now.signed_duration_since(cached.last_updated);
44
45    if age.num_days() > CACHE_TTL_DAYS {
46        tracing::info!(
47            "Pricing cache expired ({} days old, TTL: {} days)",
48            age.num_days(),
49            CACHE_TTL_DAYS
50        );
51        return Ok(None);
52    }
53
54    tracing::info!(
55        "Loaded {} model prices from cache ({} days old)",
56        cached.models.len(),
57        age.num_days()
58    );
59
60    Ok(Some(cached.models))
61}
62
63/// Save pricing to cache
64pub fn save_pricing_cache(models: HashMap<String, ModelPricing>) -> Result<()> {
65    let path = cache_path()?;
66
67    let cached = CachedPricing {
68        last_updated: chrono::Utc::now(),
69        models,
70        source: LITELLM_PRICING_URL.to_string(),
71    };
72
73    let json =
74        serde_json::to_string_pretty(&cached).context("Failed to serialize pricing cache")?;
75
76    std::fs::write(&path, json)
77        .with_context(|| format!("Failed to write pricing cache: {}", path.display()))?;
78
79    tracing::info!("Saved {} model prices to cache", cached.models.len());
80
81    Ok(())
82}
83
84/// Clear pricing cache
85pub fn clear_pricing_cache() -> Result<()> {
86    let path = cache_path()?;
87
88    if path.exists() {
89        std::fs::remove_file(&path)
90            .with_context(|| format!("Failed to remove pricing cache: {}", path.display()))?;
91        tracing::info!("Cleared pricing cache");
92    }
93
94    Ok(())
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn test_cache_path() {
103        let path = cache_path().unwrap();
104        assert!(path.to_string_lossy().contains("ccboard"));
105        assert!(path.to_string_lossy().ends_with("pricing.json"));
106    }
107}