use crate::profile::HookConfig;
use nono::{NonoError, Result};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
mod embedded {
pub const NONO_HOOK_SH: &str = include_str!(concat!(env!("OUT_DIR"), "/nono-hook.sh"));
}
fn get_embedded_script(name: &str) -> Option<&'static str> {
match name {
"nono-hook.sh" => Some(embedded::NONO_HOOK_SH),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookInstallResult {
Installed,
AlreadyInstalled,
Updated,
Skipped,
}
pub fn install_hooks(
profile_name: Option<&str>,
target: &str,
config: &HookConfig,
) -> Result<HookInstallResult> {
match target {
"claude-code" => install_claude_code_hook(profile_name, config),
other => {
tracing::warn!(
"Unknown hook target '{}', skipping hook installation",
other
);
Ok(HookInstallResult::Skipped)
}
}
}
fn install_claude_code_hook(
profile_name: Option<&str>,
config: &HookConfig,
) -> Result<HookInstallResult> {
let home = xdg_home::home_dir().ok_or(NonoError::HomeNotFound)?;
let hooks_dir = home.join(".claude").join("hooks");
let script_path = hooks_dir.join(&config.script);
let settings_path = home.join(".claude").join("settings.json");
let script_content = resolve_hook_script(profile_name, config)?;
if !hooks_dir.exists() {
tracing::info!(
"Creating Claude Code hooks directory: {}",
hooks_dir.display()
);
fs::create_dir_all(&hooks_dir).map_err(|e| {
NonoError::HookInstall(format!(
"Failed to create hooks directory {}: {}",
hooks_dir.display(),
e
))
})?;
}
let script_existed = script_path.exists();
let needs_install = if script_existed {
let existing = fs::read_to_string(&script_path).unwrap_or_default();
existing != script_content
} else {
true
};
if needs_install {
tracing::info!("Installing hook script: {}", script_path.display());
fs::write(&script_path, script_content).map_err(|e| {
NonoError::HookInstall(format!(
"Failed to write hook script {}: {}",
script_path.display(),
e
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&script_path)
.map_err(|e| NonoError::HookInstall(format!("Failed to get permissions: {}", e)))?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&script_path, perms)
.map_err(|e| NonoError::HookInstall(format!("Failed to set permissions: {}", e)))?;
}
} else {
tracing::debug!("Hook script already installed and up to date");
}
let settings_modified = update_claude_settings(&settings_path, config)?;
let result = if needs_install && !script_existed {
HookInstallResult::Installed
} else if needs_install && script_existed {
HookInstallResult::Updated
} else if settings_modified {
HookInstallResult::Installed
} else {
HookInstallResult::AlreadyInstalled
};
Ok(result)
}
fn update_claude_settings(settings_path: &PathBuf, config: &HookConfig) -> Result<bool> {
let mut settings: Value = if settings_path.exists() {
let content = fs::read_to_string(settings_path).map_err(|e| {
NonoError::HookInstall(format!(
"Failed to read settings {}: {}",
settings_path.display(),
e
))
})?;
serde_json::from_str(&content).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
let settings_obj = settings
.as_object_mut()
.ok_or_else(|| NonoError::HookInstall("settings.json is not a JSON object".to_string()))?;
if !settings_obj.contains_key("hooks") {
settings_obj.insert("hooks".to_string(), json!({}));
}
let hooks = settings_obj
.get_mut("hooks")
.and_then(|v| v.as_object_mut())
.ok_or_else(|| NonoError::HookInstall("hooks is not a JSON object".to_string()))?;
if !hooks.contains_key(&config.event) {
hooks.insert(config.event.clone(), json!([]));
}
let event_hooks = hooks
.get_mut(&config.event)
.and_then(|v| v.as_array_mut())
.ok_or_else(|| NonoError::HookInstall(format!("{} is not a JSON array", config.event)))?;
let hook_command = format!("$HOME/.claude/hooks/{}", config.script);
let hook_exists = event_hooks.iter().any(|h| {
if let Some(hooks_array) = h.get("hooks").and_then(|v| v.as_array()) {
hooks_array.iter().any(|hook| {
hook.get("command")
.and_then(|c| c.as_str())
.map(|c| c == hook_command)
.unwrap_or(false)
})
} else {
false
}
});
if !hook_exists {
tracing::info!(
"Registering hook for {} event with matcher '{}'",
config.event,
config.matcher
);
let hook_entry = json!({
"matcher": config.matcher,
"hooks": [{
"type": "command",
"command": hook_command
}]
});
event_hooks.push(hook_entry);
let content = serde_json::to_string_pretty(&settings)
.map_err(|e| NonoError::HookInstall(format!("Failed to serialize settings: {}", e)))?;
fs::write(settings_path, content).map_err(|e| {
NonoError::HookInstall(format!(
"Failed to write settings {}: {}",
settings_path.display(),
e
))
})?;
tracing::info!("Updated {}", settings_path.display());
Ok(true)
} else {
tracing::debug!("Hook already registered in settings.json");
Ok(false)
}
}
pub fn install_profile_hooks(
profile_name: Option<&str>,
hooks: &HashMap<String, HookConfig>,
) -> Result<Vec<(String, HookInstallResult)>> {
let mut results = Vec::new();
for (target, config) in hooks {
let result = install_hooks(profile_name, target, config)?;
results.push((target.clone(), result));
}
Ok(results)
}
fn resolve_hook_script(profile_name: Option<&str>, config: &HookConfig) -> Result<String> {
if let Some(profile_name) = profile_name {
if let Some(package_dir) = crate::profile::get_package_for_profile(profile_name) {
let package_script = package_dir.join("hooks").join(&config.script);
if package_script.exists() {
return fs::read_to_string(&package_script).map_err(|e| {
NonoError::HookInstall(format!(
"Failed to read package hook script {}: {}",
package_script.display(),
e
))
});
}
}
}
let user_override = crate::package::nono_config_dir()?
.join("hooks")
.join(&config.script);
if user_override.exists() {
return fs::read_to_string(&user_override).map_err(|e| {
NonoError::HookInstall(format!(
"Failed to read user hook override {}: {}",
user_override.display(),
e
))
});
}
get_embedded_script(&config.script)
.map(ToOwned::to_owned)
.ok_or_else(|| NonoError::HookInstall(format!("Unknown hook script: {}", config.script)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_embedded_script_exists() {
assert!(get_embedded_script("nono-hook.sh").is_some());
assert!(get_embedded_script("nonexistent.sh").is_none());
}
#[test]
fn test_embedded_script_content() {
let script = get_embedded_script("nono-hook.sh").expect("Script not found");
assert!(script.contains("NONO_CAP_FILE"));
assert!(script.contains("jq"));
}
}