use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct Usage {
/// Number of issues created per repo (keyed by full_name, e.g. "org/repo").
#[serde(default)]
pub repos: HashMap<String, u32>,
/// Number of issues created per project (keyed by project id).
#[serde(default)]
pub projects: HashMap<String, u32>,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct Cache {
pub repos: Vec<Repo>,
pub projects: Vec<Project>,
#[serde(default)]
pub usage: Usage,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Repo {
pub name: String,
pub full_name: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Project {
pub id: String,
pub title: String,
pub status_field_id: String,
pub status_options: Vec<StatusOption>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct StatusOption {
pub id: String,
pub name: String,
}
impl Cache {
pub fn dir() -> Result<PathBuf> {
dirs::cache_dir()
.map(|d| d.join("pike"))
.context("Could not determine cache directory")
}
pub fn load() -> Result<Self> {
let path = Self::dir()?.join("cache.json");
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(&path).context("Failed to read cache file")?;
serde_json::from_str(&content).context("Failed to parse cache file")
}
/// Increments usage counters for the given repo and (optionally) project,
/// then saves the cache. Call this after a successful issue creation.
pub fn record_issue(&mut self, repo_full_name: &str, project_id: Option<&str>) -> Result<()> {
*self
.usage
.repos
.entry(repo_full_name.to_string())
.or_insert(0) += 1;
if let Some(pid) = project_id {
*self.usage.projects.entry(pid.to_string()).or_insert(0) += 1;
}
self.save()
}
pub fn save(&self) -> Result<()> {
let dir = Self::dir()?;
std::fs::create_dir_all(&dir).context("Failed to create cache directory")?;
let path = dir.join("cache.json");
let content = serde_json::to_string_pretty(self).context("Failed to serialize cache")?;
std::fs::write(&path, content).context("Failed to write cache file")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_default() {
let cache = Cache::default();
assert!(cache.repos.is_empty());
assert!(cache.projects.is_empty());
}
#[test]
fn cache_roundtrip() {
let cache = Cache {
repos: vec![
Repo {
name: "repo1".to_string(),
full_name: "org/repo1".to_string(),
},
Repo {
name: "repo2".to_string(),
full_name: "org/repo2".to_string(),
},
],
projects: vec![Project {
id: "PVT_123".to_string(),
title: "My Project".to_string(),
status_field_id: "PVTFL_456".to_string(),
status_options: vec![
StatusOption {
id: "opt1".to_string(),
name: "Todo".to_string(),
},
StatusOption {
id: "opt2".to_string(),
name: "Done".to_string(),
},
],
}],
};
let json = serde_json::to_string_pretty(&cache).unwrap();
let deserialized: Cache = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.repos.len(), 2);
assert_eq!(deserialized.repos[0].full_name, "org/repo1");
assert_eq!(deserialized.projects.len(), 1);
assert_eq!(deserialized.projects[0].title, "My Project");
assert_eq!(deserialized.projects[0].status_options.len(), 2);
assert_eq!(deserialized.projects[0].status_options[0].name, "Todo");
}
#[test]
fn cache_save_and_load() {
let dir = std::env::temp_dir().join("pike_test_cache");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("cache.json");
let cache = Cache {
repos: vec![Repo {
name: "test".to_string(),
full_name: "org/test".to_string(),
}],
projects: vec![],
};
let content = serde_json::to_string_pretty(&cache).unwrap();
std::fs::write(&path, &content).unwrap();
let loaded: Cache = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(loaded.repos.len(), 1);
assert_eq!(loaded.repos[0].full_name, "org/test");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn repo_equality() {
let repo1 = Repo {
name: "pike".to_string(),
full_name: "anomalyco/pike".to_string(),
};
let repo2 = Repo {
name: "pike".to_string(),
full_name: "anomalyco/pike".to_string(),
};
assert_eq!(repo1.name, repo2.name);
assert_eq!(repo1.full_name, repo2.full_name);
}
#[test]
fn status_option_deserialize() {
let json = r#"{"id": "abc", "name": "In Progress"}"#;
let opt: StatusOption = serde_json::from_str(json).unwrap();
assert_eq!(opt.id, "abc");
assert_eq!(opt.name, "In Progress");
}
}