use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use crate::paths;
#[derive(Debug, Clone, Default)]
pub struct ProfileMap(pub HashMap<String, String>);
impl ProfileMap {
pub fn load() -> io::Result<Self> {
let path = paths::profiles_json();
match std::fs::read_to_string(&path) {
Ok(s) => {
let map: HashMap<String, String> = serde_json::from_str(&s).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("profiles.json parse error at {}: {e}", path.display()),
)
})?;
Ok(ProfileMap(map))
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(ProfileMap::default()),
Err(e) => Err(e),
}
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn get(&self, name: &str) -> Option<&str> {
self.0.get(name).map(String::as_str)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
pub fn names_sorted(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.0.keys().map(String::as_str).collect();
names.sort_unstable();
names
}
pub fn contains(&self, name: &str) -> bool {
self.0.contains_key(name)
}
pub fn preferred_default(&self) -> Option<String> {
self.names_sorted().first().map(|s| (*s).to_owned())
}
pub fn default_name(&self) -> String {
self.default_name_with(&crate::cas::default_state_file())
}
pub fn default_name_with(&self, state_file: &Path) -> String {
let from_file = std::fs::read_to_string(state_file)
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty());
match from_file {
Some(n) if self.is_empty() => n, Some(n) if self.contains(&n) => n, _ => self.preferred_default().unwrap_or_default(),
}
}
pub fn default_dir(&self) -> PathBuf {
let name = self.default_name();
if let Some(dir) = self.get(&name) {
return PathBuf::from(dir);
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(format!(".claude.{name}"))
}
pub fn is_valid_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
pub fn insert(&mut self, name: String, dir: String) -> Option<String> {
self.0.insert(name, dir)
}
pub fn remove(&mut self, name: &str) -> Option<String> {
self.0.remove(name)
}
pub fn save(&self) -> io::Result<()> {
self.save_to(&paths::profiles_json())
}
pub fn save_to(&self, path: &Path) -> io::Result<()> {
if let Some(p) = path.parent() {
std::fs::create_dir_all(p)?;
}
let sorted: std::collections::BTreeMap<&str, &str> = self.iter().collect();
let json = serde_json::to_string_pretty(&sorted)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, format!("{json}\n"))?;
std::fs::rename(&tmp, path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn parse_profiles(json: &str) -> io::Result<ProfileMap> {
let map: HashMap<String, String> = serde_json::from_str(json)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
Ok(ProfileMap(map))
}
#[test]
fn empty_json_object_is_empty_map() {
let pm = parse_profiles("{}").unwrap();
assert!(pm.is_empty());
}
#[test]
fn single_profile_roundtrip() {
let pm = parse_profiles(r#"{"personal": "/Users/dave/.claude.personal"}"#).unwrap();
assert!(!pm.is_empty());
assert_eq!(pm.get("personal"), Some("/Users/dave/.claude.personal"));
assert_eq!(pm.get("elyvian"), None);
}
#[test]
fn two_profiles_names_sorted() {
let pm = parse_profiles(
r#"{"elyvian": "/Users/dave/.claude.elyvian", "personal": "/Users/dave/.claude.personal"}"#,
)
.unwrap();
assert_eq!(pm.names_sorted(), vec!["elyvian", "personal"]);
}
#[test]
fn iter_yields_all_entries() {
let pm = parse_profiles(r#"{"personal": "/a", "elyvian": "/b"}"#).unwrap();
let mut pairs: Vec<(&str, &str)> = pm.iter().collect();
pairs.sort_unstable();
assert_eq!(pairs, vec![("elyvian", "/b"), ("personal", "/a")]);
}
#[test]
fn invalid_json_returns_err() {
let result = parse_profiles("not json");
assert!(result.is_err());
}
#[test]
fn absent_file_returns_empty_map() {
let path = std::path::PathBuf::from("/tmp/csm-test-absent-profiles-NOPE.json");
let result = match std::fs::read_to_string(&path) {
Ok(s) => {
let m: HashMap<String, String> = serde_json::from_str(&s).unwrap();
Ok(ProfileMap(m))
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(ProfileMap::default()),
Err(e) => Err(e),
};
let pm = result.unwrap();
assert!(pm.is_empty());
}
#[test]
fn real_file_roundtrip() {
let mut f = NamedTempFile::new().unwrap();
write!(
f,
r#"{{"personal": "/Users/dave/.claude.personal", "elyvian": "/Users/dave/.claude.elyvian"}}"#
)
.unwrap();
let s = std::fs::read_to_string(f.path()).unwrap();
let pm = parse_profiles(&s).unwrap();
assert_eq!(pm.get("personal"), Some("/Users/dave/.claude.personal"));
assert_eq!(pm.get("elyvian"), Some("/Users/dave/.claude.elyvian"));
}
#[test]
fn contains_reflects_membership() {
let pm = parse_profiles(r#"{"a": "/x", "b": "/y"}"#).unwrap();
assert!(pm.contains("a"));
assert!(!pm.contains("c"));
assert!(!ProfileMap::default().contains("a"));
}
#[test]
fn preferred_default_is_alphabetical_first() {
let pm = parse_profiles(r#"{"zeta": "/z", "alpha": "/a"}"#).unwrap();
assert_eq!(pm.preferred_default().as_deref(), Some("alpha"));
}
#[test]
fn preferred_default_single_and_empty() {
let single = parse_profiles(r#"{"only": "/o"}"#).unwrap();
assert_eq!(single.preferred_default().as_deref(), Some("only"));
assert_eq!(ProfileMap::default().preferred_default(), None);
}
#[test]
fn default_name_with_resolves_in_order() {
let pm = parse_profiles(r#"{"alpha": "/a", "beta": "/b"}"#).unwrap();
let f = write_state("beta\n");
assert_eq!(pm.default_name_with(f.path()), "beta");
let f = write_state("nope");
assert_eq!(pm.default_name_with(f.path()), "alpha");
let f = write_state("custom\n");
assert_eq!(ProfileMap::default().default_name_with(f.path()), "custom");
let f = write_state("");
assert_eq!(ProfileMap::default().default_name_with(f.path()), "");
}
#[test]
fn default_dir_maps_or_synthesizes() {
let pm = parse_profiles(r#"{"alpha": "/explicit/alpha"}"#).unwrap();
let f = write_state("alpha\n");
assert_eq!(pm.default_name_with(f.path()), "alpha");
assert_eq!(pm.get("alpha"), Some("/explicit/alpha"));
}
#[test]
fn is_valid_name_rules() {
assert!(ProfileMap::is_valid_name("personal"));
assert!(ProfileMap::is_valid_name("a.b_c-1"));
assert!(!ProfileMap::is_valid_name(""));
assert!(!ProfileMap::is_valid_name("a b"));
assert!(!ProfileMap::is_valid_name("a/b"));
assert!(!ProfileMap::is_valid_name("a\\b"));
}
#[test]
fn insert_remove_mutate() {
let mut pm = ProfileMap::default();
assert_eq!(pm.insert("x".into(), "/x".into()), None);
assert_eq!(pm.insert("x".into(), "/x2".into()), Some("/x".to_owned()));
assert!(pm.contains("x"));
assert_eq!(pm.remove("x"), Some("/x2".to_owned()));
assert!(!pm.contains("x"));
assert_eq!(pm.remove("x"), None);
}
#[test]
fn save_to_then_load_roundtrip_sorted() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("profiles.json");
let mut pm = ProfileMap::default();
pm.insert("zeta".into(), "/z".into());
pm.insert("alpha".into(), "/a".into());
pm.save_to(&path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.ends_with('\n'));
assert!(
raw.find("alpha").unwrap() < raw.find("zeta").unwrap(),
"keys not sorted: {raw}"
);
let reloaded = parse_profiles(&raw).unwrap();
assert_eq!(reloaded.get("alpha"), Some("/a"));
assert_eq!(reloaded.get("zeta"), Some("/z"));
}
#[test]
fn save_to_is_atomic_no_tmp_left() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("profiles.json");
let mut pm = ProfileMap::default();
pm.insert("x".into(), "/x".into());
pm.save_to(&path).unwrap();
assert!(!path.with_extension("json.tmp").exists());
assert!(path.exists());
}
fn write_state(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
write!(f, "{content}").unwrap();
f
}
}