use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::config;
const DEFAULT_TTL_SECONDS: u64 = 3600;
#[derive(Debug, Clone, Copy, Default)]
pub struct CacheOptions {
pub ttl_seconds: Option<u64>,
pub no_cache: bool,
}
impl CacheOptions {
pub fn effective_ttl_seconds(&self) -> u64 {
self.ttl_seconds.unwrap_or(DEFAULT_TTL_SECONDS)
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CacheEntry {
pub timestamp: u64,
pub ttl_seconds: u64,
pub data: Value,
}
impl CacheEntry {
pub fn is_valid(&self) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
now < self.timestamp + self.ttl_seconds
}
pub fn age_seconds(&self) -> u64 {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
now.saturating_sub(self.timestamp)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheType {
Teams,
Users,
Statuses,
Labels,
Projects,
}
impl CacheType {
pub fn filename(&self) -> &'static str {
match self {
CacheType::Teams => "teams.json",
CacheType::Users => "users.json",
CacheType::Statuses => "statuses.json",
CacheType::Labels => "labels.json",
CacheType::Projects => "projects.json",
}
}
pub fn display_name(&self) -> &'static str {
match self {
CacheType::Teams => "Teams",
CacheType::Users => "Users",
CacheType::Statuses => "Statuses",
CacheType::Labels => "Labels",
CacheType::Projects => "Projects",
}
}
pub fn all() -> &'static [CacheType] {
&[
CacheType::Teams,
CacheType::Users,
CacheType::Statuses,
CacheType::Labels,
CacheType::Projects,
]
}
}
pub struct Cache {
cache_dir: PathBuf,
ttl_seconds: u64,
}
impl Cache {
pub fn new() -> Result<Self> {
Self::with_ttl(DEFAULT_TTL_SECONDS)
}
pub fn with_ttl(ttl_seconds: u64) -> Result<Self> {
let cache_dir = Self::cache_dir()?;
fs::create_dir_all(&cache_dir)?;
Ok(Self {
cache_dir,
ttl_seconds,
})
}
fn cache_dir() -> Result<PathBuf> {
let profile = config::current_profile().unwrap_or_else(|_| "default".to_string());
let config_dir = dirs::config_dir()
.context("Could not find config directory")?
.join("linear-cli")
.join("cache")
.join(profile);
Ok(config_dir)
}
fn cache_path(&self, cache_type: CacheType) -> PathBuf {
self.cache_dir.join(cache_type.filename())
}
pub fn get(&self, cache_type: CacheType) -> Option<Value> {
let path = self.cache_path(cache_type);
if !path.exists() {
return None;
}
let content = fs::read_to_string(&path).ok()?;
let entry: CacheEntry = serde_json::from_str(&content).ok()?;
if entry.is_valid() {
Some(entry.data)
} else {
let _ = fs::remove_file(&path);
None
}
}
pub fn get_entry(&self, cache_type: CacheType) -> Option<CacheEntry> {
let path = self.cache_path(cache_type);
if !path.exists() {
return None;
}
let content = fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
}
pub fn set(&self, cache_type: CacheType, data: Value) -> Result<()> {
let path = self.cache_path(cache_type);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
let entry = CacheEntry {
timestamp,
ttl_seconds: self.ttl_seconds,
data,
};
let content = serde_json::to_string_pretty(&entry)?;
let temp_path = path.with_extension("tmp");
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&temp_path)?;
file.write_all(content.as_bytes())?;
file.sync_all()?;
}
#[cfg(not(unix))]
{
let mut file = fs::File::create(&temp_path)?;
file.write_all(content.as_bytes())?;
file.sync_all()?;
}
fs::rename(&temp_path, &path)?;
Ok(())
}
pub fn clear_type(&self, cache_type: CacheType) -> Result<()> {
let path = self.cache_path(cache_type);
if path.exists() {
fs::remove_file(path)?;
}
Ok(())
}
pub fn clear_all(&self) -> Result<()> {
for cache_type in CacheType::all() {
self.clear_type(*cache_type)?;
}
Ok(())
}
pub fn get_keyed(&self, cache_type: CacheType, key: &str) -> Option<Value> {
let data = self.get(cache_type)?;
data.get(key).cloned()
}
pub fn set_keyed(&self, cache_type: CacheType, key: &str, value: Value) -> Result<()> {
let mut data = self.get(cache_type).unwrap_or_else(|| json!({}));
if let Some(obj) = data.as_object_mut() {
obj.insert(key.to_string(), value);
}
self.set(cache_type, data)
}
pub fn status(&self) -> Vec<CacheStatus> {
CacheType::all()
.iter()
.map(|cache_type| {
let path = self.cache_path(*cache_type);
let (valid, age_seconds, size_bytes, item_count) = if path.exists() {
if let Some(entry) = self.get_entry(*cache_type) {
let size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
let count = entry
.data
.as_array()
.map(|a| a.len())
.or_else(|| {
entry
.data
.get("nodes")
.and_then(|n| n.as_array())
.map(|a| a.len())
})
.unwrap_or(1);
(
entry.is_valid(),
Some(entry.age_seconds()),
Some(size),
Some(count),
)
} else {
(false, None, None, None)
}
} else {
(false, None, None, None)
};
CacheStatus {
cache_type: *cache_type,
valid,
age_seconds,
size_bytes,
item_count,
}
})
.collect()
}
}
pub fn cache_dir_path() -> Result<PathBuf> {
let profile = config::current_profile().unwrap_or_else(|_| "default".to_string());
let config_dir = dirs::config_dir()
.context("Could not find config directory")?
.join("linear-cli")
.join("cache")
.join(profile);
Ok(config_dir)
}
#[derive(Debug)]
pub struct CacheStatus {
pub cache_type: CacheType,
pub valid: bool,
pub age_seconds: Option<u64>,
pub size_bytes: Option<u64>,
pub item_count: Option<usize>,
}
impl CacheStatus {
pub fn age_display(&self) -> String {
match self.age_seconds {
Some(secs) if secs < 60 => format!("{}s", secs),
Some(secs) if secs < 3600 => format!("{}m", secs / 60),
Some(secs) => format!("{}h {}m", secs / 3600, (secs % 3600) / 60),
None => "-".to_string(),
}
}
pub fn size_display(&self) -> String {
match self.size_bytes {
Some(bytes) if bytes < 1024 => format!("{} B", bytes),
Some(bytes) if bytes < 1024 * 1024 => format!("{:.1} KB", bytes as f64 / 1024.0),
Some(bytes) => format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)),
None => "-".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_entry_validity() {
let entry = CacheEntry {
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
ttl_seconds: 3600,
data: serde_json::json!({"test": "data"}),
};
assert!(entry.is_valid());
}
#[test]
fn test_cache_entry_expired() {
let entry = CacheEntry {
timestamp: 0, ttl_seconds: 3600,
data: serde_json::json!({"test": "data"}),
};
assert!(!entry.is_valid());
}
}