use crate::paths::Paths;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SlotRecord {
pub name: String,
pub id: String,
pub config_dir: PathBuf,
#[serde(default)]
pub adopted: bool,
}
pub struct Slots {
file: PathBuf,
slots_dir: PathBuf,
records: Vec<SlotRecord>,
}
fn new_id(name: &str) -> String {
use sha2::{Digest, Sha256};
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let mut h = Sha256::new();
h.update(name.as_bytes());
h.update(nanos.to_le_bytes());
h.finalize()
.iter()
.take(8)
.map(|b| format!("{b:02x}"))
.collect()
}
impl Slots {
pub fn open(paths: &Paths) -> Result<Slots> {
let store = paths.store_dir();
let file = store.join("slots.json");
let records = if file.exists() {
let bytes = std::fs::read(&file).context("read slots.json")?;
serde_json::from_slice(&bytes).context("slots.json is corrupt")?
} else {
Vec::new()
};
Ok(Slots {
file,
slots_dir: store.join("slots"),
records,
})
}
pub fn get(&self, name: &str) -> Option<SlotRecord> {
self.records.iter().find(|r| r.name == name).cloned()
}
pub fn list(&self) -> Vec<SlotRecord> {
self.records.clone()
}
pub fn create(&mut self, name: &str) -> Result<SlotRecord> {
let name = name.trim();
if name.is_empty() {
bail!("a slot name is required");
}
if self.records.iter().any(|r| r.name == name) {
bail!("a slot named '{name}' already exists");
}
let id = new_id(name);
let config_dir = self.slots_dir.join(&id);
std::fs::create_dir_all(&config_dir).context("create slot dir")?;
let rec = SlotRecord {
name: name.to_string(),
id,
config_dir,
adopted: false,
};
self.records.push(rec.clone());
self.persist()?;
Ok(rec)
}
fn persist(&self) -> Result<()> {
if let Some(parent) = self.file.parent() {
std::fs::create_dir_all(parent).context("create store dir")?;
}
let bytes = serde_json::to_vec_pretty(&self.records)?;
std::fs::write(&self.file, bytes).context("write slots.json")?;
Ok(())
}
pub fn adopt(&mut self, name: &str, config_dir: &std::path::Path) -> Result<SlotRecord> {
let name = name.trim();
if name.is_empty() {
bail!("a slot name is required");
}
if self.records.iter().any(|r| r.name == name) {
bail!("a slot named '{name}' already exists");
}
if !config_dir.is_absolute() {
bail!("config dir must be an absolute path");
}
if !config_dir.is_dir() {
bail!("config dir does not exist: {}", config_dir.display());
}
let rec = SlotRecord {
name: name.to_string(),
id: new_id(name),
config_dir: config_dir.to_path_buf(),
adopted: true,
};
self.records.push(rec.clone());
self.persist()?;
Ok(rec)
}
fn pointer_file(&self) -> PathBuf {
let store = self
.file
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default();
store.join("active-claude")
}
pub fn set_default(&self, name: &str) -> Result<()> {
let rec = self
.get(name)
.with_context(|| format!("no slot named '{name}'"))?;
let p = self.pointer_file();
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).context("create store dir")?;
}
std::fs::write(&p, rec.config_dir.to_string_lossy().as_bytes())
.context("write active-claude pointer")?;
Ok(())
}
pub fn default_dir(&self) -> Option<PathBuf> {
let s = std::fs::read_to_string(self.pointer_file()).ok()?;
let s = s.trim();
(!s.is_empty()).then(|| PathBuf::from(s))
}
}
pub const SHARED_CONFIG_FILES: &[&str] = &["settings.json", "CLAUDE.md"];
pub fn link_shared_config(slot: &std::path::Path, source: &std::path::Path) -> Vec<String> {
let mut linked = Vec::new();
for name in SHARED_CONFIG_FILES {
let src = source.join(name);
let dst = slot.join(name);
if src.exists() && !dst.exists() {
#[cfg(unix)]
if std::os::unix::fs::symlink(&src, &dst).is_ok() {
linked.push((*name).to_string());
}
}
}
linked
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::Paths;
#[test]
fn create_persists_and_reloads() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let rec = {
let mut s = Slots::open(&paths).unwrap();
s.create("work").unwrap()
};
assert!(rec.config_dir.is_absolute());
assert!(rec.config_dir.starts_with(paths.store_dir().join("slots")));
assert!(rec.config_dir.is_dir(), "slot dir was created");
assert!(!rec.adopted);
let s2 = Slots::open(&paths).unwrap();
assert_eq!(s2.get("work").unwrap().id, rec.id);
assert_eq!(s2.list().len(), 1);
}
#[test]
fn duplicate_and_empty_names_are_rejected() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let mut s = Slots::open(&paths).unwrap();
s.create("work").unwrap();
assert!(s.create("work").is_err(), "duplicate name rejected");
assert!(s.create(" ").is_err(), "empty name rejected");
}
#[test]
fn id_is_stable_and_name_independent() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let mut s = Slots::open(&paths).unwrap();
let a = s.create("alpha").unwrap();
let b = s.create("beta").unwrap();
assert_ne!(a.id, b.id);
assert_ne!(a.id, "alpha", "id is opaque, not the display name");
}
#[test]
fn set_default_points_at_the_slot_dir() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let mut s = Slots::open(&paths).unwrap();
let rec = s.create("work").unwrap();
assert_eq!(s.default_dir(), None, "no default until set");
s.set_default("work").unwrap();
assert_eq!(s.default_dir(), Some(rec.config_dir.clone()));
assert_eq!(
Slots::open(&paths).unwrap().default_dir(),
Some(rec.config_dir)
);
assert!(s.set_default("missing").is_err(), "unknown name rejected");
}
#[test]
fn adopt_registers_an_existing_dir_without_moving_it() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let existing = root.path().join("dot-claude-company");
std::fs::create_dir_all(&existing).unwrap();
let mut s = Slots::open(&paths).unwrap();
let rec = s.adopt("company", &existing).unwrap();
assert_eq!(rec.config_dir, existing, "config dir is the existing path");
assert!(rec.adopted);
assert!(existing.is_dir(), "the existing dir is left in place");
assert!(s.adopt("nope", &root.path().join("absent")).is_err());
}
}