use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use crate::snapshots::SnapshotScope;
use crate::templates::{AutoCompactWindow, TemplateType};
pub const PREFS_VERSION: &str = "v1";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum KeyRef {
Credential(String),
EnvVar(String),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TemplatePref {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variant: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_key: Option<KeyRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_scope: Option<SnapshotScope>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_effort: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_co_author: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_auto_compact_window: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_context_window: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_used_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Prefs {
#[serde(default = "default_version")]
pub version: String,
#[serde(default)]
pub default_scope: SnapshotScope,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_effort: Option<String>,
#[serde(default)]
pub default_co_author: bool,
#[serde(default)]
pub templates: HashMap<String, TemplatePref>,
}
fn default_version() -> String {
PREFS_VERSION.to_string()
}
impl Default for Prefs {
fn default() -> Self {
Self {
version: PREFS_VERSION.to_string(),
default_scope: SnapshotScope::Common,
default_effort: None,
default_co_author: false,
templates: HashMap::new(),
}
}
}
impl Prefs {
pub fn path() -> PathBuf {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".claude").join("ccs-prefs.json")
}
pub fn exists() -> bool {
Self::path().exists()
}
pub fn load_or_default() -> Self {
Self::load().unwrap_or_default()
}
pub fn load() -> Result<Self> {
let path = Self::path();
if !path.exists() {
return Err(anyhow!("prefs file not found at {}", path.display()));
}
let content = fs::read_to_string(&path)
.map_err(|e| anyhow!("Failed to read prefs {}: {}", path.display(), e))?;
let mut prefs: Prefs = serde_json::from_str(&content)
.map_err(|e| anyhow!("Failed to parse prefs {}: {}", path.display(), e))?;
if prefs.version.is_empty() {
prefs.version = PREFS_VERSION.to_string();
}
Ok(prefs)
}
pub fn save(&self) -> Result<()> {
let path = Self::path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| anyhow!("Failed to create prefs dir {}: {}", parent.display(), e))?;
}
let content = serde_json::to_string_pretty(self)
.map_err(|e| anyhow!("Failed to serialize prefs: {}", e))?;
fs::write(&path, content)
.map_err(|e| anyhow!("Failed to write prefs {}: {}", path.display(), e))?;
Ok(())
}
fn key_for(template_type: &TemplateType) -> String {
template_type.to_string()
}
pub fn template_pref(&self, template_type: &TemplateType) -> Option<&TemplatePref> {
self.templates.get(&Self::key_for(template_type))
}
pub fn template_pref_mut(&mut self, template_type: &TemplateType) -> &mut TemplatePref {
self.templates
.entry(Self::key_for(template_type))
.or_default()
}
pub fn set_variant(&mut self, template_type: &TemplateType, variant: Option<String>) {
let pref = self.template_pref_mut(template_type);
pref.variant = variant;
pref.last_used_at = Some(crate::utils::get_timestamp());
}
pub fn set_last_key(&mut self, template_type: &TemplateType, key: Option<KeyRef>) {
let pref = self.template_pref_mut(template_type);
pref.last_key = key;
pref.last_used_at = Some(crate::utils::get_timestamp());
}
pub fn set_last_scope(&mut self, template_type: &TemplateType, scope: SnapshotScope) {
let pref = self.template_pref_mut(template_type);
pref.last_scope = Some(scope);
pref.last_used_at = Some(crate::utils::get_timestamp());
}
pub fn set_last_effort(&mut self, template_type: &TemplateType, effort: Option<String>) {
let pref = self.template_pref_mut(template_type);
pref.last_effort = effort;
pref.last_used_at = Some(crate::utils::get_timestamp());
}
pub fn set_last_co_author(&mut self, template_type: &TemplateType, co_author: bool) {
let pref = self.template_pref_mut(template_type);
pref.last_co_author = Some(co_author);
pref.last_used_at = Some(crate::utils::get_timestamp());
}
pub fn set_last_auto_compact_window(
&mut self,
template_type: &TemplateType,
auto_compact_window: Option<AutoCompactWindow>,
) {
let pref = self.template_pref_mut(template_type);
pref.last_auto_compact_window = auto_compact_window.map(|c| c.to_string());
pref.last_context_window = None;
pref.last_used_at = Some(crate::utils::get_timestamp());
}
pub fn record_apply(
&mut self,
template_type: &TemplateType,
variant: Option<String>,
key: Option<KeyRef>,
scope: SnapshotScope,
effort: Option<String>,
co_author: bool,
auto_compact_window: Option<AutoCompactWindow>,
) {
let pref = self.template_pref_mut(template_type);
pref.variant = variant;
pref.last_key = key;
pref.last_scope = Some(scope);
pref.last_effort = effort;
pref.last_co_author = Some(co_author);
pref.last_auto_compact_window = auto_compact_window.map(|c| c.to_string());
pref.last_context_window = None;
pref.last_used_at = Some(crate::utils::get_timestamp());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_prefs_roundtrip() {
let mut prefs = Prefs {
default_effort: Some("max".to_string()),
..Default::default()
};
prefs.set_variant(&TemplateType::Zai, Some("zai-china".to_string()));
prefs.set_last_key(
&TemplateType::Zai,
Some(KeyRef::Credential("abc-123".to_string())),
);
let json = serde_json::to_string(&prefs).unwrap();
let restored: Prefs = serde_json::from_str(&json).unwrap();
assert_eq!(restored.default_effort.as_deref(), Some("max"));
assert_eq!(
restored
.template_pref(&TemplateType::Zai)
.unwrap()
.variant
.as_deref(),
Some("zai-china")
);
assert_eq!(
restored.template_pref(&TemplateType::Zai).unwrap().last_key,
Some(KeyRef::Credential("abc-123".to_string()))
);
}
#[test]
fn test_prefs_auto_compact_window_roundtrip() {
let mut prefs = Prefs::default();
prefs.set_last_auto_compact_window(&TemplateType::Zai, Some(AutoCompactWindow::K512));
let json = serde_json::to_string(&prefs).unwrap();
let restored: Prefs = serde_json::from_str(&json).unwrap();
assert_eq!(
restored
.template_pref(&TemplateType::Zai)
.unwrap()
.last_auto_compact_window
.as_deref(),
Some("512k")
);
}
#[test]
fn test_prefs_template_absent_by_default() {
let prefs = Prefs::default();
assert!(prefs.template_pref(&TemplateType::DeepSeek).is_none());
}
#[test]
fn test_prefs_keyref_serde_roundtrip() {
let cases = vec![
KeyRef::EnvVar("Z_AI_API_KEY".to_string()),
KeyRef::Credential("cred-id".to_string()),
];
for k in cases {
let json = serde_json::to_string(&k).unwrap();
let restored: KeyRef = serde_json::from_str(&json).unwrap();
assert_eq!(k, restored);
}
}
#[test]
fn test_prefs_template_pref_mut_creates_entry() {
let mut prefs = Prefs::default();
prefs.set_variant(&TemplateType::Kimi, Some("k2".to_string()));
assert_eq!(
prefs
.template_pref(&TemplateType::Kimi)
.unwrap()
.variant
.as_deref(),
Some("k2")
);
}
}