use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClaudeConfigPaths {
pub user_settings: PathBuf,
pub user_local_settings: PathBuf,
pub project_settings: PathBuf,
pub project_local_settings: PathBuf,
pub user_agents_dir: PathBuf,
pub project_agents_dir: PathBuf,
}
pub struct ClaudeConfigReader;
impl ClaudeConfigReader {
pub fn paths_for_project(project: &Path) -> ClaudeConfigPaths {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let user_claude = home.join(".claude");
let project_claude = project.join(".claude");
ClaudeConfigPaths {
user_settings: user_claude.join("settings.json"),
user_local_settings: user_claude.join("settings.local.json"),
project_settings: project_claude.join("settings.json"),
project_local_settings: project_claude.join("settings.local.json"),
user_agents_dir: user_claude.join("agents"),
project_agents_dir: project_claude.join("agents"),
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClaudeConfig {
pub has_hooks: bool,
pub allow_list_has_wildcard: bool,
pub allow_list_entries: usize,
pub has_agents: bool,
pub has_openrouter_key: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Info,
Warning,
Critical,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigRecommendation {
pub id: String,
pub severity: Severity,
pub title: String,
pub description: String,
pub auto_applicable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigCheckpoint {
pub id: String,
pub created_at: String,
pub project: PathBuf,
pub label: Option<String>,
pub files: HashMap<String, String>,
}
pub struct CheckpointPaths;
impl CheckpointPaths {
pub fn dir(project: &Path) -> PathBuf {
project.join(".trusty-mpm").join("checkpoints")
}
pub fn for_id(project: &Path, id: &str) -> PathBuf {
Self::dir(project).join(format!("{id}.json"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum DeployTarget {
User,
Project,
Both,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HookConfig {
pub pre_tool_use: Vec<String>,
pub post_tool_use: Vec<String>,
pub stop: Vec<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PermissionConfig {
pub allow: Vec<String>,
pub deny: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeploymentProfile {
pub name: String,
pub description: String,
pub target: DeployTarget,
pub hooks: Option<HookConfig>,
pub permissions: Option<PermissionConfig>,
pub env_vars: HashMap<String, String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paths_for_project_resolves_all() {
let paths = ClaudeConfigReader::paths_for_project(Path::new("/work/demo"));
assert!(paths.project_settings.ends_with(".claude/settings.json"));
assert!(
paths
.project_local_settings
.ends_with(".claude/settings.local.json")
);
assert!(paths.project_agents_dir.ends_with(".claude/agents"));
assert!(
paths.project_settings.starts_with("/work/demo"),
"project paths must be under the project dir"
);
assert!(paths.user_settings.ends_with(".claude/settings.json"));
}
#[test]
fn claude_config_json_roundtrip() {
let cfg = ClaudeConfig {
has_hooks: true,
allow_list_has_wildcard: false,
allow_list_entries: 3,
has_agents: true,
has_openrouter_key: false,
};
let json = serde_json::to_string(&cfg).unwrap();
let back: ClaudeConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back, cfg);
}
#[test]
fn severity_json_roundtrip() {
for sev in [Severity::Info, Severity::Warning, Severity::Critical] {
let json = serde_json::to_string(&sev).unwrap();
let back: Severity = serde_json::from_str(&json).unwrap();
assert_eq!(back, sev);
}
assert_eq!(
serde_json::to_string(&Severity::Critical).unwrap(),
"\"critical\""
);
}
#[test]
fn recommendation_json_roundtrip() {
let rec = ConfigRecommendation {
id: "add-trusty-hooks".into(),
severity: Severity::Warning,
title: "No hooks configured".into(),
description: "Add pre/post tool-use hooks for oversight.".into(),
auto_applicable: false,
};
let json = serde_json::to_string(&rec).unwrap();
let back: ConfigRecommendation = serde_json::from_str(&json).unwrap();
assert_eq!(back, rec);
}
#[test]
fn checkpoint_paths_resolve() {
let project = Path::new("/work/demo");
assert!(
CheckpointPaths::dir(project).ends_with(".trusty-mpm/checkpoints"),
"checkpoint dir is project-local"
);
assert!(
CheckpointPaths::for_id(project, "checkpoint-x")
.ends_with(".trusty-mpm/checkpoints/checkpoint-x.json"),
"checkpoint file is <id>.json"
);
}
#[test]
fn config_checkpoint_json_roundtrip() {
let mut files = HashMap::new();
files.insert(".claude/settings.json".to_string(), "{}".to_string());
let cp = ConfigCheckpoint {
id: "checkpoint-20260517-153000-a1b2".into(),
created_at: "2026-05-17T15:30:00+00:00".into(),
project: PathBuf::from("/work/demo"),
label: Some("before-apply".into()),
files,
};
let json = serde_json::to_string(&cp).unwrap();
let back: ConfigCheckpoint = serde_json::from_str(&json).unwrap();
assert_eq!(back, cp);
}
#[test]
fn deploy_target_json_roundtrip() {
for target in [
DeployTarget::User,
DeployTarget::Project,
DeployTarget::Both,
] {
let json = serde_json::to_string(&target).unwrap();
let back: DeployTarget = serde_json::from_str(&json).unwrap();
assert_eq!(back, target);
}
assert_eq!(
serde_json::to_string(&DeployTarget::Project).unwrap(),
"\"project\""
);
}
#[test]
fn hook_config_json_roundtrip() {
let hooks = HookConfig {
pre_tool_use: vec!["curl pre".into()],
post_tool_use: vec!["curl post".into()],
stop: vec![],
};
let json = serde_json::to_string(&hooks).unwrap();
let back: HookConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back, hooks);
}
#[test]
fn permission_config_json_roundtrip() {
let perms = PermissionConfig {
allow: vec!["Read".into(), "Glob".into()],
deny: vec!["Bash".into()],
};
let json = serde_json::to_string(&perms).unwrap();
let back: PermissionConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back, perms);
}
#[test]
fn deployment_profile_json_roundtrip() {
let mut env_vars = HashMap::new();
env_vars.insert("OPENROUTER_API_KEY".to_string(), "sk-x".to_string());
let profile = DeploymentProfile {
name: "trusty-mpm-oversight".into(),
description: "Full oversight".into(),
target: DeployTarget::Both,
hooks: Some(HookConfig::default()),
permissions: Some(PermissionConfig::default()),
env_vars,
};
let json = serde_json::to_string(&profile).unwrap();
let back: DeploymentProfile = serde_json::from_str(&json).unwrap();
assert_eq!(back, profile);
}
}