use crate::domain::preset::{PresetLockEntry, PresetLockfile, PresetManifest};
use crate::error::CarryCtxError;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::Path;
pub struct PresetManager<'a> {
pub repo_root: &'a Path,
}
impl<'a> PresetManager<'a> {
pub fn new(repo_root: &'a Path) -> Self {
Self { repo_root }
}
fn carryctx_dir(&self) -> std::path::PathBuf {
self.repo_root.join(".carryctx")
}
fn presets_dir(&self) -> std::path::PathBuf {
self.carryctx_dir().join("presets")
}
fn lockfile_path(&self) -> std::path::PathBuf {
self.carryctx_dir().join("presets.lock")
}
pub fn read_lockfile(&self) -> Result<PresetLockfile, CarryCtxError> {
let lock_path = self.lockfile_path();
if !lock_path.exists() {
return Ok(PresetLockfile::default());
}
let content = fs::read_to_string(&lock_path).map_err(|e| {
CarryCtxError::database_error(format!("Failed to read presets.lock: {e}"))
})?;
toml::from_str(&content).map_err(|e| {
CarryCtxError::database_error(format!("Failed to parse presets.lock: {e}"))
})
}
pub fn write_lockfile(&self, lockfile: &PresetLockfile) -> Result<(), CarryCtxError> {
let lock_path = self.lockfile_path();
if let Some(parent) = lock_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
CarryCtxError::database_error(format!("Failed to create .carryctx dir: {e}"))
})?;
}
let content = toml::to_string_pretty(lockfile).map_err(|e| {
CarryCtxError::database_error(format!("Failed to serialize presets.lock: {e}"))
})?;
let final_content = format!(
"# This file is automatically generated by carryctx.\n# Do not edit manually.\n\n{}",
content
);
fs::write(&lock_path, final_content).map_err(|e| {
CarryCtxError::database_error(format!("Failed to write presets.lock: {e}"))
})
}
pub fn install_preset(&self, source_path: &Path) -> Result<PresetLockEntry, CarryCtxError> {
let content = fs::read_to_string(source_path).map_err(|e| {
CarryCtxError::resource_not_found(format!(
"Failed to read preset source {}: {}",
source_path.display(),
e
))
})?;
let manifest: PresetManifest = serde_json::from_str(&content).map_err(|e| {
CarryCtxError::invalid_arguments(format!("Failed to parse preset JSON: {e}"))
})?;
if !manifest
.name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '/')
|| manifest.name.contains("//")
|| manifest.name.starts_with('/')
|| manifest.name.contains("..")
{
return Err(CarryCtxError::invalid_arguments(format!(
"Invalid preset name '{}': must be alphanumeric with hyphens, underscores, and slashes only",
manifest.name
)));
}
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
let hash = format!("sha256-{}", hex::encode(hasher.finalize()));
let presets_dir = self.presets_dir();
let target_file = presets_dir.join(format!("{}.json", manifest.name));
if let Some(parent) = target_file.parent() {
fs::create_dir_all(parent).map_err(|e| {
CarryCtxError::database_error(format!("Failed to create preset directory: {e}"))
})?;
}
fs::write(&target_file, &content).map_err(|e| {
CarryCtxError::database_error(format!(
"Failed to save preset to {}: {}",
target_file.display(),
e
))
})?;
let mut lockfile = self.read_lockfile()?;
let entry = PresetLockEntry {
version: manifest.version.clone(),
source: source_path.to_string_lossy().to_string(),
integrity: hash,
permissions_granted: manifest.permissions.clone(),
};
lockfile
.presets
.insert(manifest.name.clone(), entry.clone());
self.write_lockfile(&lockfile)?;
Ok(entry)
}
pub fn activate_preset(&self, name: &str) -> Result<PresetLockEntry, CarryCtxError> {
let lockfile = self.read_lockfile()?;
let entry = lockfile.presets.get(name).cloned().ok_or_else(|| {
CarryCtxError::resource_not_found(format!(
"Preset '{}' is not installed in presets.lock",
name
))
})?;
let preset_path = self.presets_dir().join(format!("{}.json", name));
if !preset_path.exists() {
return Err(CarryCtxError::resource_not_found(format!(
"Preset file missing: {}",
preset_path.display()
)));
}
let content = fs::read_to_string(&preset_path).map_err(|e| {
CarryCtxError::database_error(format!(
"Failed to read {}: {}",
preset_path.display(),
e
))
})?;
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
let current_hash = format!("sha256-{}", hex::encode(hasher.finalize()));
if current_hash != entry.integrity {
return Err(CarryCtxError::state_conflict(format!(
"Integrity check failed for preset '{}'. Expected {} but got {}",
name, entry.integrity, current_hash
)));
}
Ok(entry)
}
}