use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use serde_json::Value;
use trusty_mpm_core::claude_config::{
CheckpointPaths, ClaudeConfig, ClaudeConfigPaths, ConfigCheckpoint, ConfigRecommendation,
DeployTarget, DeploymentProfile, HookConfig, PermissionConfig, Severity,
};
use trusty_mpm_core::tmux::TmuxTarget;
use trusty_mpm_core::{Error, Result};
pub struct ClaudeConfigAnalyzer;
impl ClaudeConfigAnalyzer {
pub fn read_config(paths: &ClaudeConfigPaths) -> ClaudeConfig {
let mut config = ClaudeConfig::default();
for settings_path in [
&paths.user_settings,
&paths.user_local_settings,
&paths.project_settings,
&paths.project_local_settings,
] {
if let Some(json) = read_json(settings_path) {
merge_settings(&mut config, &json);
}
}
config.has_agents = dir_has_agent_files(&paths.user_agents_dir)
|| dir_has_agent_files(&paths.project_agents_dir);
config
}
pub fn analyze(config: &ClaudeConfig) -> Vec<ConfigRecommendation> {
let mut recs = Vec::new();
if !config.has_hooks {
recs.push(ConfigRecommendation {
id: "add-trusty-hooks".into(),
severity: Severity::Warning,
title: "No hooks configured".into(),
description: "Claude Code has no hooks. Add pre/post tool-use \
hooks so trusty-mpm can observe and oversee tool calls."
.into(),
auto_applicable: true,
});
}
if config.allow_list_has_wildcard {
recs.push(ConfigRecommendation {
id: "scope-permissions".into(),
severity: Severity::Critical,
title: "Permission allow list contains a wildcard".into(),
description: "The `permissions.allow` list contains `*`, which \
grants every tool unconditionally. Scope it to the specific tools the project \
needs."
.into(),
auto_applicable: false,
});
}
if !config.has_agents {
recs.push(ConfigRecommendation {
id: "deploy-agents".into(),
severity: Severity::Info,
title: "No agents deployed".into(),
description: "No agent files were found. Deploy the trusty-mpm \
agents so delegated work runs under managed agents."
.into(),
auto_applicable: false,
});
}
if !config.has_openrouter_key {
recs.push(ConfigRecommendation {
id: "add-openrouter-key".into(),
severity: Severity::Warning,
title: "OPENROUTER_API_KEY not in env hooks".into(),
description: "The LLM overseer needs `OPENROUTER_API_KEY`. Add \
it to the Claude Code `env` block (or to `.env.local`)."
.into(),
auto_applicable: false,
});
}
recs.sort_by_key(|r| std::cmp::Reverse(severity_rank(r.severity)));
recs
}
pub fn apply_recommendation(
rec: &ConfigRecommendation,
paths: &ClaudeConfigPaths,
project: &Path,
) -> Result<String> {
let label = format!("before-{}", rec.id);
let checkpoint_id = ConfigCheckpointer::create(paths, project, Some(&label))?;
match rec.id.as_str() {
"add-trusty-hooks" => {
apply_add_hooks(&paths.project_settings)?;
Ok(checkpoint_id)
}
other => Err(Error::Protocol(format!(
"recommendation `{other}` is not auto-applicable; apply it manually"
))),
}
}
}
fn severity_rank(severity: Severity) -> u8 {
match severity {
Severity::Info => 0,
Severity::Warning => 1,
Severity::Critical => 2,
}
}
fn read_json(path: &Path) -> Option<Value> {
let raw = std::fs::read_to_string(path).ok()?;
match serde_json::from_str(&raw) {
Ok(json) => Some(json),
Err(e) => {
tracing::warn!("malformed Claude config {}: {e}; skipping", path.display());
None
}
}
}
fn merge_settings(config: &mut ClaudeConfig, json: &Value) {
if let Some(hooks) = json.get("hooks").and_then(Value::as_object)
&& !hooks.is_empty()
{
config.has_hooks = true;
}
if let Some(allow) = json
.get("permissions")
.and_then(|p| p.get("allow"))
.and_then(Value::as_array)
{
config.allow_list_entries += allow.len();
if allow.iter().any(|v| v.as_str() == Some("*")) {
config.allow_list_has_wildcard = true;
}
}
if let Some(env) = json.get("env").and_then(Value::as_object)
&& env.contains_key("OPENROUTER_API_KEY")
{
config.has_openrouter_key = true;
}
}
fn dir_has_agent_files(dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries.flatten().any(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| x.eq_ignore_ascii_case("md"))
})
}
fn apply_add_hooks(settings_path: &Path) -> Result<()> {
let mut json: Value = read_json(settings_path).unwrap_or_else(|| serde_json::json!({}));
let hooks = serde_json::json!({
"PreToolUse": [{ "matcher": "*", "hooks": [
{ "type": "command", "command": "trusty-mpm hook" }
] }],
"PostToolUse": [{ "matcher": "*", "hooks": [
{ "type": "command", "command": "trusty-mpm hook" }
] }],
"Stop": [{ "matcher": "*", "hooks": [
{ "type": "command", "command": "trusty-mpm hook" }
] }],
});
if let Some(obj) = json.as_object_mut() {
obj.insert("hooks".to_string(), hooks);
} else {
json = serde_json::json!({ "hooks": hooks });
}
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent).map_err(Error::Io)?;
}
let pretty = serde_json::to_string_pretty(&json)
.map_err(|e| Error::Protocol(format!("serialize settings.json: {e}")))?;
std::fs::write(settings_path, pretty).map_err(Error::Io)?;
Ok(())
}
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 = trusty_mpm_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()
}
pub struct ProfileDeployer;
impl ProfileDeployer {
pub fn builtin_profiles() -> Vec<DeploymentProfile> {
vec![
DeploymentProfile {
name: "trusty-mpm-oversight".into(),
description: "Full oversight: PreToolUse/PostToolUse hooks POST \
to the trusty-mpm daemon, standard dev tools allowed."
.into(),
target: DeployTarget::Project,
hooks: Some(HookConfig {
pre_tool_use: vec![OVERSIGHT_PRE_HOOK.to_string()],
post_tool_use: vec![OVERSIGHT_POST_HOOK.to_string()],
stop: vec![],
}),
permissions: Some(PermissionConfig {
allow: vec![
"Read".into(),
"Glob".into(),
"Grep".into(),
"Edit".into(),
"Write".into(),
"Bash".into(),
],
deny: vec![],
}),
env_vars: HashMap::new(),
},
DeploymentProfile {
name: "read-only-review".into(),
description: "Code review mode: only Read/Glob/Grep allowed; \
Bash/Write/Edit are denied."
.into(),
target: DeployTarget::Project,
hooks: None,
permissions: Some(PermissionConfig {
allow: vec!["Read".into(), "Glob".into(), "Grep".into()],
deny: vec!["Bash".into(), "Write".into(), "Edit".into()],
}),
env_vars: HashMap::new(),
},
DeploymentProfile {
name: "minimal".into(),
description: "Clean slate: no hooks, permissive allow list.".into(),
target: DeployTarget::Project,
hooks: None,
permissions: Some(PermissionConfig {
allow: vec!["Read".into(), "Glob".into(), "Grep".into()],
deny: vec![],
}),
env_vars: HashMap::new(),
},
]
}
pub fn deploy(
profile: &DeploymentProfile,
paths: &ClaudeConfigPaths,
project: &Path,
) -> Result<String> {
let label = format!("before-deploy-{}", profile.name);
let checkpoint_id = ConfigCheckpointer::create(paths, project, Some(&label))?;
let mut targets: Vec<&Path> = Vec::new();
match profile.target {
DeployTarget::User => targets.push(&paths.user_settings),
DeployTarget::Project => targets.push(&paths.project_settings),
DeployTarget::Both => {
targets.push(&paths.user_settings);
targets.push(&paths.project_settings);
}
}
for settings_path in targets {
write_profile_to_settings(profile, settings_path)?;
}
Ok(checkpoint_id)
}
pub fn list_applied(paths: &ClaudeConfigPaths) -> Result<Vec<String>> {
let mut merged: Vec<Value> = Vec::new();
for path in [
&paths.user_settings,
&paths.user_local_settings,
&paths.project_settings,
&paths.project_local_settings,
] {
if let Some(json) = read_json(path) {
merged.push(json);
}
}
let applied = Self::builtin_profiles()
.into_iter()
.filter(|p| profile_is_applied(p, &merged))
.map(|p| p.name)
.collect();
Ok(applied)
}
}
const OVERSIGHT_PRE_HOOK: &str = "curl -s -X POST http://localhost:7373/hooks -H 'Content-Type: application/json' -d '{\"session_id\":\"${CLAUDE_SESSION_ID}\",\"event\":\"PreToolUse\",\"payload\":{\"tool\":\"${CLAUDE_TOOL_NAME}\",\"input\":${CLAUDE_TOOL_INPUT}}}' || true";
const OVERSIGHT_POST_HOOK: &str = "curl -s -X POST http://localhost:7373/hooks -H 'Content-Type: application/json' -d '{\"session_id\":\"${CLAUDE_SESSION_ID}\",\"event\":\"PostToolUse\",\"payload\":{\"tool\":\"${CLAUDE_TOOL_NAME}\",\"output\":\"done\"}}' || true";
fn profile_is_applied(profile: &DeploymentProfile, merged: &[Value]) -> bool {
let deny: Vec<&str> = profile
.permissions
.as_ref()
.map(|p| p.deny.iter().map(String::as_str).collect())
.unwrap_or_default();
let hook_cmds: Vec<&str> = profile
.hooks
.as_ref()
.map(|h| {
h.pre_tool_use
.iter()
.chain(&h.post_tool_use)
.chain(&h.stop)
.map(String::as_str)
.collect()
})
.unwrap_or_default();
if deny.is_empty() && hook_cmds.is_empty() {
return false;
}
let blob = merged
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join("\n");
deny.iter().all(|d| {
blob.contains(&format!("\"{d}\""))
}) && hook_cmds.iter().all(|c| blob.contains(c))
}
fn write_profile_to_settings(profile: &DeploymentProfile, settings_path: &Path) -> Result<()> {
let mut json: Value = read_json(settings_path).unwrap_or_else(|| serde_json::json!({}));
let obj = match json.as_object_mut() {
Some(obj) => obj,
None => {
json = serde_json::json!({});
json.as_object_mut().expect("freshly built object")
}
};
if let Some(hooks) = &profile.hooks {
obj.insert("hooks".to_string(), hook_config_to_json(hooks));
}
if let Some(perms) = &profile.permissions {
obj.insert(
"permissions".to_string(),
serde_json::json!({ "allow": perms.allow, "deny": perms.deny }),
);
}
if !profile.env_vars.is_empty() {
let env = obj
.entry("env".to_string())
.or_insert_with(|| serde_json::json!({}));
if let Some(env_obj) = env.as_object_mut() {
for (k, v) in &profile.env_vars {
env_obj.insert(k.clone(), Value::String(v.clone()));
}
}
}
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent).map_err(Error::Io)?;
}
let pretty = serde_json::to_string_pretty(&json)
.map_err(|e| Error::Protocol(format!("serialize settings.json: {e}")))?;
std::fs::write(settings_path, pretty).map_err(Error::Io)?;
Ok(())
}
fn hook_config_to_json(hooks: &HookConfig) -> Value {
let mut obj = serde_json::Map::new();
for (event, commands) in [
("PreToolUse", &hooks.pre_tool_use),
("PostToolUse", &hooks.post_tool_use),
("Stop", &hooks.stop),
] {
if commands.is_empty() {
continue;
}
let entries: Vec<Value> = commands
.iter()
.map(|cmd| {
serde_json::json!({
"matcher": "",
"hooks": [{ "type": "command", "command": cmd }],
})
})
.collect();
obj.insert(event.to_string(), Value::Array(entries));
}
Value::Object(obj)
}
pub struct ClaudeCodeRestarter;
impl ClaudeCodeRestarter {
pub fn find_claude_processes() -> Vec<u32> {
let output = match Command::new("pgrep").args(["-x", "claude"]).output() {
Ok(out) => out,
Err(e) => {
tracing::info!("pgrep unavailable: {e}; reporting no claude processes");
return Vec::new();
}
};
if !output.status.success() {
return Vec::new();
}
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|l| l.trim().parse::<u32>().ok())
.collect()
}
pub fn restart_in_session(tmux_session: &str) -> Result<()> {
let driver = crate::tmux::TmuxDriver::discover()?;
let target = TmuxTarget::session(tmux_session);
driver.send_interrupt(&target)?;
std::thread::sleep(std::time::Duration::from_millis(500));
driver.send_line(&target, "claude")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_mpm_core::claude_config::ClaudeConfigReader;
fn temp_paths(root: &Path) -> ClaudeConfigPaths {
let project = root.join("project");
let user = root.join("home");
ClaudeConfigPaths {
user_settings: user.join(".claude/settings.json"),
user_local_settings: user.join(".claude/settings.local.json"),
project_settings: project.join(".claude/settings.json"),
project_local_settings: project.join(".claude/settings.local.json"),
user_agents_dir: user.join(".claude/agents"),
project_agents_dir: project.join(".claude/agents"),
}
}
fn write_json(path: &Path, json: &Value) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, serde_json::to_string_pretty(json).unwrap()).unwrap();
}
#[test]
fn read_config_missing_files_is_empty() {
let dir = tempfile::tempdir().unwrap();
let paths = temp_paths(dir.path());
let config = ClaudeConfigAnalyzer::read_config(&paths);
assert_eq!(config, ClaudeConfig::default());
}
#[test]
fn read_config_detects_hooks() {
let dir = tempfile::tempdir().unwrap();
let paths = temp_paths(dir.path());
write_json(
&paths.project_settings,
&serde_json::json!({ "hooks": { "PreToolUse": [] } }),
);
let config = ClaudeConfigAnalyzer::read_config(&paths);
assert!(config.has_hooks);
}
#[test]
fn read_config_detects_wildcard_and_env() {
let dir = tempfile::tempdir().unwrap();
let paths = temp_paths(dir.path());
write_json(
&paths.project_settings,
&serde_json::json!({
"permissions": { "allow": ["*", "Read"] },
"env": { "OPENROUTER_API_KEY": "sk-x" } }),
);
let config = ClaudeConfigAnalyzer::read_config(&paths);
assert!(config.allow_list_has_wildcard);
assert_eq!(config.allow_list_entries, 2);
assert!(config.has_openrouter_key);
}
#[test]
fn read_config_detects_agents() {
let dir = tempfile::tempdir().unwrap();
let paths = temp_paths(dir.path());
std::fs::create_dir_all(&paths.project_agents_dir).unwrap();
std::fs::write(paths.project_agents_dir.join("research.md"), "# agent").unwrap();
let config = ClaudeConfigAnalyzer::read_config(&paths);
assert!(config.has_agents);
}
#[test]
fn analyze_flags_missing_hooks() {
let recs = ClaudeConfigAnalyzer::analyze(&ClaudeConfig::default());
assert!(recs.iter().any(|r| r.id == "add-trusty-hooks"));
}
#[test]
fn analyze_flags_wildcard() {
let config = ClaudeConfig {
has_hooks: true,
allow_list_has_wildcard: true,
allow_list_entries: 1,
has_agents: true,
has_openrouter_key: true,
};
let recs = ClaudeConfigAnalyzer::analyze(&config);
let wildcard = recs.iter().find(|r| r.id == "scope-permissions");
assert!(wildcard.is_some());
assert_eq!(wildcard.unwrap().severity, Severity::Critical);
}
#[test]
fn analyze_clean_config_is_empty() {
let config = ClaudeConfig {
has_hooks: true,
allow_list_has_wildcard: false,
allow_list_entries: 5,
has_agents: true,
has_openrouter_key: true,
};
assert!(ClaudeConfigAnalyzer::analyze(&config).is_empty());
}
#[test]
fn apply_add_hooks_writes_settings() {
let dir = tempfile::tempdir().unwrap();
let paths = temp_paths(dir.path());
let project = dir.path().join("project");
let rec = ClaudeConfigAnalyzer::analyze(&ClaudeConfig::default())
.into_iter()
.find(|r| r.id == "add-trusty-hooks")
.expect("add-trusty-hooks recommended");
ClaudeConfigAnalyzer::apply_recommendation(&rec, &paths, &project).expect("apply succeeds");
let config = ClaudeConfigAnalyzer::read_config(&paths);
assert!(config.has_hooks, "hooks block must be present after apply");
}
#[test]
fn apply_manual_rec_errors() {
let dir = tempfile::tempdir().unwrap();
let paths = temp_paths(dir.path());
let project = dir.path().join("project");
let rec = ConfigRecommendation {
id: "scope-permissions".into(),
severity: Severity::Critical,
title: "x".into(),
description: "x".into(),
auto_applicable: false,
};
assert!(ClaudeConfigAnalyzer::apply_recommendation(&rec, &paths, &project).is_err());
}
fn healthy_config() -> ClaudeConfig {
ClaudeConfig {
has_hooks: true,
allow_list_has_wildcard: false,
allow_list_entries: 5,
has_agents: true,
has_openrouter_key: true,
}
}
#[test]
fn analyze_missing_hooks_flags_warning() {
let config = ClaudeConfig {
has_hooks: false,
..healthy_config()
};
let recs = ClaudeConfigAnalyzer::analyze(&config);
let rec = recs
.iter()
.find(|r| r.id == "add-trusty-hooks")
.expect("add-trusty-hooks flagged");
assert_eq!(rec.severity, Severity::Warning);
assert_eq!(recs.len(), 1, "only the missing-hooks issue");
}
#[test]
fn analyze_wildcard_permission_flags_critical() {
let config = ClaudeConfig {
allow_list_has_wildcard: true,
..healthy_config()
};
let recs = ClaudeConfigAnalyzer::analyze(&config);
let rec = recs
.iter()
.find(|r| r.id == "scope-permissions")
.expect("scope-permissions flagged");
assert_eq!(rec.severity, Severity::Critical);
assert_eq!(recs.len(), 1, "only the wildcard issue");
}
#[test]
fn analyze_no_agents_flags_info() {
let config = ClaudeConfig {
has_agents: false,
..healthy_config()
};
let recs = ClaudeConfigAnalyzer::analyze(&config);
let rec = recs
.iter()
.find(|r| r.id == "deploy-agents")
.expect("deploy-agents flagged");
assert_eq!(rec.severity, Severity::Info);
assert_eq!(recs.len(), 1, "only the missing-agents issue");
}
#[test]
fn analyze_missing_openrouter_key_flags_warning() {
let config = ClaudeConfig {
has_openrouter_key: false,
..healthy_config()
};
let recs = ClaudeConfigAnalyzer::analyze(&config);
let rec = recs
.iter()
.find(|r| r.id == "add-openrouter-key")
.expect("add-openrouter-key flagged");
assert_eq!(rec.severity, Severity::Warning);
assert_eq!(recs.len(), 1, "only the missing-key issue");
}
#[test]
fn analyze_fully_configured_is_empty() {
assert!(ClaudeConfigAnalyzer::analyze(&healthy_config()).is_empty());
}
#[test]
fn analyze_partial_config_multiple_recs() {
let config = ClaudeConfig {
has_hooks: false,
allow_list_has_wildcard: true,
..healthy_config()
};
let recs = ClaudeConfigAnalyzer::analyze(&config);
assert_eq!(recs.len(), 2, "exactly the two flagged issues");
assert_eq!(
recs[0].severity,
Severity::Critical,
"Critical sorts before Warning"
);
assert_eq!(recs[0].id, "scope-permissions");
assert_eq!(recs[1].severity, Severity::Warning);
assert_eq!(recs[1].id, "add-trusty-hooks");
}
#[test]
fn apply_creates_checkpoint_before_change() {
let dir = tempfile::tempdir().unwrap();
let paths = temp_paths(dir.path());
let project = dir.path().join("project");
let rec = ClaudeConfigAnalyzer::analyze(&ClaudeConfig::default())
.into_iter()
.find(|r| r.id == "add-trusty-hooks")
.expect("add-trusty-hooks recommended");
let checkpoint_id = ClaudeConfigAnalyzer::apply_recommendation(&rec, &paths, &project)
.expect("apply succeeds");
let cp_file =
trusty_mpm_core::claude_config::CheckpointPaths::for_id(&project, &checkpoint_id);
assert!(cp_file.exists(), "checkpoint JSON must exist after apply");
let checkpoints = ConfigCheckpointer::list(&project).unwrap();
assert_eq!(checkpoints.len(), 1);
assert_eq!(checkpoints[0].id, checkpoint_id);
assert_eq!(
checkpoints[0].label.as_deref(),
Some("before-add-trusty-hooks")
);
}
#[test]
fn restore_reverts_to_pre_apply_state() {
let dir = tempfile::tempdir().unwrap();
let paths = temp_paths(dir.path());
let project = dir.path().join("project");
write_json(&paths.project_settings, &serde_json::json!({ "x": 1 }));
let rec = ConfigRecommendation {
id: "add-trusty-hooks".into(),
severity: Severity::Warning,
title: "x".into(),
description: "x".into(),
auto_applicable: true,
};
let checkpoint_id = ClaudeConfigAnalyzer::apply_recommendation(&rec, &paths, &project)
.expect("apply succeeds");
assert!(
ClaudeConfigAnalyzer::read_config(&paths).has_hooks,
"hooks present after apply"
);
ConfigCheckpointer::restore(&project, &checkpoint_id).expect("restore succeeds");
let restored: Value =
serde_json::from_str(&std::fs::read_to_string(&paths.project_settings).unwrap())
.unwrap();
assert_eq!(restored, serde_json::json!({ "x": 1 }), "original content");
assert!(
!ClaudeConfigAnalyzer::read_config(&paths).has_hooks,
"hooks gone after restore"
);
}
#[test]
fn checkpoint_list_newest_first() {
let dir = tempfile::tempdir().unwrap();
let paths = temp_paths(dir.path());
let project = dir.path().join("project");
let mut ids = Vec::new();
for label in ["first", "second", "third"] {
let id = ConfigCheckpointer::create(&paths, &project, Some(label)).unwrap();
ids.push(id);
std::thread::sleep(std::time::Duration::from_millis(1100));
}
let listed = ConfigCheckpointer::list(&project).unwrap();
assert_eq!(listed.len(), 3);
assert_eq!(listed[0].id, ids[2], "newest first");
assert_eq!(listed[1].id, ids[1]);
assert_eq!(listed[2].id, ids[0], "oldest last");
}
#[test]
fn safe_restore_does_not_delete_new_files() {
let dir = tempfile::tempdir().unwrap();
let paths = temp_paths(dir.path());
let project = dir.path().join("project");
write_json(&paths.project_settings, &serde_json::json!({ "a": 1 }));
let checkpoint_id = ConfigCheckpointer::create(&paths, &project, Some("snapshot")).unwrap();
write_json(
&paths.project_local_settings,
&serde_json::json!({ "new": true }),
);
ConfigCheckpointer::restore(&project, &checkpoint_id).expect("restore succeeds");
assert!(
paths.project_local_settings.exists(),
"file created after the checkpoint must not be deleted by restore"
);
let still: Value =
serde_json::from_str(&std::fs::read_to_string(&paths.project_local_settings).unwrap())
.unwrap();
assert_eq!(still, serde_json::json!({ "new": true }));
}
#[test]
fn checkpoint_delete_removes_file() {
let dir = tempfile::tempdir().unwrap();
let paths = temp_paths(dir.path());
let project = dir.path().join("project");
let id = ConfigCheckpointer::create(&paths, &project, None).unwrap();
assert_eq!(ConfigCheckpointer::list(&project).unwrap().len(), 1);
ConfigCheckpointer::delete(&project, &id).expect("delete succeeds");
assert!(ConfigCheckpointer::list(&project).unwrap().is_empty());
}
#[test]
fn builtin_profiles_are_present() {
let names: Vec<String> = ProfileDeployer::builtin_profiles()
.into_iter()
.map(|p| p.name)
.collect();
assert!(names.contains(&"trusty-mpm-oversight".to_string()));
assert!(names.contains(&"read-only-review".to_string()));
assert!(names.contains(&"minimal".to_string()));
}
#[test]
fn deploy_trusty_oversight_profile_writes_hooks() {
let dir = tempfile::tempdir().unwrap();
let paths = temp_paths(dir.path());
let project = dir.path().join("project");
let profile = ProfileDeployer::builtin_profiles()
.into_iter()
.find(|p| p.name == "trusty-mpm-oversight")
.expect("oversight profile exists");
ProfileDeployer::deploy(&profile, &paths, &project).expect("deploy succeeds");
let settings: Value =
serde_json::from_str(&std::fs::read_to_string(&paths.project_settings).unwrap())
.unwrap();
assert!(
settings["hooks"]["PreToolUse"].is_array(),
"PreToolUse hooks written"
);
assert!(
settings["hooks"]["PostToolUse"].is_array(),
"PostToolUse hooks written"
);
assert!(
ClaudeConfigAnalyzer::read_config(&paths).has_hooks,
"deployed hooks are detected by the analyzer"
);
}
#[test]
fn deploy_readonly_profile_writes_deny_list() {
let dir = tempfile::tempdir().unwrap();
let paths = temp_paths(dir.path());
let project = dir.path().join("project");
let profile = ProfileDeployer::builtin_profiles()
.into_iter()
.find(|p| p.name == "read-only-review")
.expect("read-only profile exists");
ProfileDeployer::deploy(&profile, &paths, &project).expect("deploy succeeds");
let settings: Value =
serde_json::from_str(&std::fs::read_to_string(&paths.project_settings).unwrap())
.unwrap();
let deny = settings["permissions"]["deny"]
.as_array()
.expect("deny list present");
assert!(deny.iter().any(|v| v == "Bash"));
assert!(deny.iter().any(|v| v == "Write"));
assert!(deny.iter().any(|v| v == "Edit"));
}
#[test]
fn list_applied_detects_deployed_profile() {
let dir = tempfile::tempdir().unwrap();
let paths = temp_paths(dir.path());
let project = dir.path().join("project");
let profile = ProfileDeployer::builtin_profiles()
.into_iter()
.find(|p| p.name == "read-only-review")
.expect("read-only profile exists");
ProfileDeployer::deploy(&profile, &paths, &project).expect("deploy succeeds");
let applied = ProfileDeployer::list_applied(&paths).unwrap();
assert!(applied.contains(&"read-only-review".to_string()));
}
#[test]
fn deploy_creates_checkpoint() {
let dir = tempfile::tempdir().unwrap();
let paths = temp_paths(dir.path());
let project = dir.path().join("project");
let profile = ProfileDeployer::builtin_profiles()
.into_iter()
.find(|p| p.name == "minimal")
.expect("minimal profile exists");
let checkpoint_id =
ProfileDeployer::deploy(&profile, &paths, &project).expect("deploy succeeds");
let listed = ConfigCheckpointer::list(&project).unwrap();
assert!(listed.iter().any(|c| c.id == checkpoint_id));
}
#[test]
fn find_claude_processes_does_not_panic() {
let _pids = ClaudeCodeRestarter::find_claude_processes();
}
#[test]
fn paths_for_project_is_usable() {
let paths = ClaudeConfigReader::paths_for_project(Path::new("/work/demo"));
assert!(paths.project_settings.ends_with(".claude/settings.json"));
}
}