use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProjectBinding {
pub persona: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AppConfig {
#[serde(default)]
pub active_persona: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub projects: BTreeMap<String, ProjectBinding>,
}
impl AppConfig {
pub fn load(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(path).context("Failed to read config.toml")?;
let config: AppConfig = toml::from_str(&content).context("Failed to parse config.toml")?;
Ok(config)
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self).context("Failed to serialize config")?;
std::fs::write(path, content)?;
Ok(())
}
pub fn binding(&self, scope: &Scope) -> Option<&str> {
match scope {
Scope::Global => self.active_persona.as_deref(),
Scope::Project(cwd) => self
.projects
.get(&project_key(cwd))
.map(|b| b.persona.as_str()),
}
}
pub fn set_binding(&mut self, scope: &Scope, persona: Option<String>) {
match scope {
Scope::Global => self.active_persona = persona,
Scope::Project(cwd) => {
let key = project_key(cwd);
match persona {
Some(name) => {
self.projects.insert(key, ProjectBinding { persona: name });
}
None => {
self.projects.remove(&key);
}
}
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Scope {
Global,
Project(PathBuf),
}
impl Scope {
pub fn is_global(&self) -> bool {
matches!(self, Scope::Global)
}
}
pub fn project_key(cwd: &Path) -> String {
cwd.to_string_lossy().into_owned()
}
#[derive(Debug, Clone)]
pub struct Target {
pub settings_file: PathBuf,
pub skills_dir: PathBuf,
pub claude_md_file: Option<PathBuf>,
pub claude_json: PathBuf,
pub claude_json_project_key: Option<String>,
pub snapshot_path: PathBuf,
pub backups_dir: PathBuf,
pub include_cc_persona_skill: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectMeta {
pub project_path: String,
#[serde(default)]
pub created: Option<String>,
#[serde(default)]
pub last_used: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Paths {
pub root: PathBuf,
pub config: PathBuf,
pub active_persona_state: PathBuf,
pub personas: PathBuf,
pub skill_sets: PathBuf,
pub skill_store: PathBuf,
pub claude_md: PathBuf,
pub backups: PathBuf,
pub claude_settings: PathBuf,
pub claude_skills: PathBuf,
pub claude_md_file: PathBuf,
pub claude_json: PathBuf,
}
impl Paths {
pub fn new() -> Result<Self> {
let home = dirs::home_dir().context("Cannot determine home directory")?;
let root = home.join(".cc-persona");
let (claude_dir, claude_json) = claude_config_base(&home);
Ok(Self {
config: root.join("config.toml"),
active_persona_state: root.join("active-persona-state.json"),
personas: root.join("personas"),
skill_sets: root.join("skill-sets"),
skill_store: root.join("skill-store"),
claude_md: root.join("claude-md"),
backups: root.join("backups"),
root,
claude_settings: claude_dir.join("settings.json"),
claude_skills: claude_dir.join("skills"),
claude_md_file: claude_dir.join("CLAUDE.md"),
claude_json,
})
}
pub fn ensure_dirs(&self) -> Result<()> {
for dir in [
&self.root,
&self.personas,
&self.skill_sets,
&self.skill_store,
&self.claude_md,
&self.backups,
] {
std::fs::create_dir_all(dir)?;
}
Ok(())
}
pub fn global_target(&self) -> Target {
Target {
settings_file: self.claude_settings.clone(),
skills_dir: self.claude_skills.clone(),
claude_md_file: Some(self.claude_md_file.clone()),
claude_json: self.claude_json.clone(),
claude_json_project_key: None,
snapshot_path: self.active_persona_state.clone(),
backups_dir: self.backups.clone(),
include_cc_persona_skill: true,
}
}
pub fn resolve_target(&self, scope: &Scope) -> Target {
match scope {
Scope::Global => self.global_target(),
Scope::Project(cwd) => {
let claude_dir = cwd.join(".claude");
let state_root = self.project_state_root(cwd);
Target {
settings_file: claude_dir.join("settings.local.json"),
skills_dir: claude_dir.join("skills"),
claude_md_file: None,
claude_json: self.claude_json.clone(),
claude_json_project_key: Some(project_key(cwd)),
snapshot_path: state_root.join("active-persona-state.json"),
backups_dir: state_root.join("backups"),
include_cc_persona_skill: false,
}
}
}
}
pub fn project_state_root(&self, cwd: &Path) -> PathBuf {
self.root.join("projects").join(encode_project_dir(cwd))
}
pub fn projects_root(&self) -> PathBuf {
self.root.join("projects")
}
}
fn claude_config_base(home: &Path) -> (PathBuf, PathBuf) {
match std::env::var_os("CLAUDE_CONFIG_DIR") {
Some(dir) if !dir.is_empty() => {
let dir = PathBuf::from(dir);
let json = dir.join(".claude.json");
(dir, json)
}
_ => (home.join(".claude"), home.join(".claude.json")),
}
}
pub fn encode_project_dir(cwd: &Path) -> String {
let full = cwd.to_string_lossy();
let sanitized: String = full
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let stem = sanitized.trim_matches('-');
let tail: String = {
let chars: Vec<char> = stem.chars().collect();
let start = chars.len().saturating_sub(40);
chars[start..].iter().collect()
};
format!("{}-{:08x}", tail.trim_matches('-'), fnv1a_hash(full.as_bytes()))
}
fn fnv1a_hash(bytes: &[u8]) -> u32 {
let mut hash: u32 = 0x811c_9dc5;
for &b in bytes {
hash ^= b as u32;
hash = hash.wrapping_mul(0x0100_0193);
}
hash
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::TestEnv;
#[test]
fn load_returns_default_when_file_missing() {
let env = TestEnv::new();
let missing = env.paths.root.join("does-not-exist").join("config.toml");
assert!(!missing.exists());
let config = AppConfig::load(&missing).unwrap();
assert_eq!(config.active_persona, None);
}
#[test]
fn save_then_load_round_trips_active_persona() {
let env = TestEnv::new();
let config = AppConfig {
active_persona: Some("engineer".to_string()),
..Default::default()
};
config.save(&env.paths.config).unwrap();
let loaded = AppConfig::load(&env.paths.config).unwrap();
assert_eq!(loaded.active_persona, Some("engineer".to_string()));
}
#[test]
fn save_creates_missing_parent_directories() {
let env = TestEnv::new();
let nested = env
.paths
.root
.join("deeply")
.join("nested")
.join("config.toml");
assert!(!nested.parent().unwrap().exists());
AppConfig::default().save(&nested).unwrap();
assert!(nested.exists());
}
#[test]
fn ensure_dirs_creates_all_directories_including_skill_store() {
let env = TestEnv::new();
assert!(!env.paths.skill_store.exists());
env.paths.ensure_dirs().unwrap();
for dir in [
&env.paths.root,
&env.paths.personas,
&env.paths.skill_sets,
&env.paths.skill_store,
&env.paths.claude_md,
&env.paths.backups,
] {
assert!(
dir.is_dir(),
"expected directory to exist: {}",
dir.display()
);
}
}
#[test]
fn ensure_dirs_is_idempotent_when_run_twice() {
let env = TestEnv::new();
env.paths.ensure_dirs().unwrap();
env.paths.ensure_dirs().unwrap();
assert!(env.paths.skill_store.is_dir());
}
}