use std::collections::HashMap;
use std::path::Path;
use crate::core::claude_config::{CheckpointPaths, ClaudeConfigPaths, ConfigCheckpoint};
use crate::core::{Error, Result};
pub(super) fn checkpoint_targets(paths: &ClaudeConfigPaths) -> [(&'static str, &Path); 4] {
[
("user/settings.json", paths.user_settings.as_path()),
(
"user/settings.local.json",
paths.user_local_settings.as_path(),
),
("project/settings.json", paths.project_settings.as_path()),
(
"project/settings.local.json",
paths.project_local_settings.as_path(),
),
]
}
pub struct ConfigCheckpointer;
impl ConfigCheckpointer {
pub fn create(
paths: &ClaudeConfigPaths,
project: &Path,
label: Option<&str>,
) -> Result<String> {
let now = chrono::Utc::now();
let id = format!(
"checkpoint-{}-{}",
now.format("%Y%m%d-%H%M%S"),
random_suffix()
);
let mut files = HashMap::new();
for (key, path) in checkpoint_targets(paths) {
if let Ok(content) = std::fs::read_to_string(path) {
files.insert(key.to_string(), content);
}
}
let checkpoint = ConfigCheckpoint {
id: id.clone(),
created_at: now.to_rfc3339(),
project: project.to_path_buf(),
label: label.map(str::to_string),
files,
};
let dir = CheckpointPaths::dir(project);
std::fs::create_dir_all(&dir).map_err(Error::Io)?;
let file = CheckpointPaths::for_id(project, &id);
let json = serde_json::to_string_pretty(&checkpoint)
.map_err(|e| Error::Protocol(format!("serialize checkpoint: {e}")))?;
std::fs::write(&file, json).map_err(Error::Io)?;
tracing::info!("created config checkpoint {id} for {}", project.display());
Ok(id)
}
pub fn list(project: &Path) -> Result<Vec<ConfigCheckpoint>> {
let dir = CheckpointPaths::dir(project);
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(_) => return Ok(Vec::new()),
};
let mut checkpoints: Vec<ConfigCheckpoint> = entries
.flatten()
.filter(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| x.eq_ignore_ascii_case("json"))
})
.filter_map(|e| {
let raw = std::fs::read_to_string(e.path()).ok()?;
match serde_json::from_str::<ConfigCheckpoint>(&raw) {
Ok(cp) => Some(cp),
Err(err) => {
tracing::warn!(
"skipping malformed checkpoint {}: {err}",
e.path().display()
);
None
}
}
})
.collect();
checkpoints.sort_by_key(|c| std::cmp::Reverse(c.created_at.clone()));
Ok(checkpoints)
}
pub fn restore(project: &Path, checkpoint_id: &str) -> Result<()> {
let file = CheckpointPaths::for_id(project, checkpoint_id);
let raw = std::fs::read_to_string(&file)
.map_err(|e| Error::Protocol(format!("checkpoint `{checkpoint_id}` not found: {e}")))?;
let checkpoint: ConfigCheckpoint = serde_json::from_str(&raw)
.map_err(|e| Error::Protocol(format!("malformed checkpoint `{checkpoint_id}`: {e}")))?;
let paths = crate::core::claude_config::ClaudeConfigReader::paths_for_project(project);
for (key, path) in checkpoint_targets(&paths) {
if let Some(content) = checkpoint.files.get(key) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(Error::Io)?;
}
std::fs::write(path, content).map_err(Error::Io)?;
}
}
tracing::info!(
"restored config checkpoint {checkpoint_id} for {}",
project.display()
);
Ok(())
}
pub fn delete(project: &Path, checkpoint_id: &str) -> Result<()> {
let file = CheckpointPaths::for_id(project, checkpoint_id);
std::fs::remove_file(&file).map_err(|e| {
Error::Protocol(format!("cannot delete checkpoint `{checkpoint_id}`: {e}"))
})
}
}
fn random_suffix() -> String {
uuid::Uuid::new_v4().simple().to_string()[..4].to_string()
}