use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::cli::commands::{HookEvent, HookTool};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub(crate) struct InstalledHook {
pub scope: String,
pub project: String,
pub event: String,
pub hook_type: String,
pub provider: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skill_providers: Vec<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub provider_args: String,
}
#[derive(Serialize, Deserialize, Default, Debug)]
pub(crate) struct InstalledHooksFile {
#[serde(default)]
pub hooks: Vec<InstalledHook>,
}
fn installed_hooks_path() -> Option<PathBuf> {
linthis::utils::home_dir().map(|h| h.join(".linthis").join("installed-hooks.toml"))
}
pub(crate) fn load_installed_hooks() -> InstalledHooksFile {
let path = match installed_hooks_path() {
Some(p) => p,
None => return InstalledHooksFile::default(),
};
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => return InstalledHooksFile::default(),
};
toml::from_str(&raw).unwrap_or_default()
}
pub(crate) fn save_installed_hook(
scope: &str,
project: &str,
event: &HookEvent,
hook_type: &HookTool,
provider: Option<&str>,
provider_args: Option<&str>,
) {
save_installed_hook_inner(
scope,
project,
event,
hook_type,
provider,
&[],
provider_args,
);
}
fn save_installed_hook_inner(
scope: &str,
project: &str,
event: &HookEvent,
hook_type: &HookTool,
provider: Option<&str>,
skill_providers: &[&str],
provider_args: Option<&str>,
) {
let path = match installed_hooks_path() {
Some(p) => p,
None => return,
};
let mut file = load_installed_hooks();
let event_str = event.as_str().to_string();
let hook_type_str = hook_type.as_str().to_string();
let provider_str = provider.unwrap_or("").to_string();
let existing = file.hooks.iter_mut().find(|h| {
h.scope == scope
&& h.project == project
&& h.event == event_str
&& h.hook_type == hook_type_str
});
let provider_args_str = provider_args.unwrap_or("").to_string();
if let Some(entry) = existing {
entry.provider = provider_str;
if !skill_providers.is_empty() {
entry.skill_providers = skill_providers.iter().map(|s| s.to_string()).collect();
}
entry.provider_args = provider_args_str;
} else {
file.hooks.push(InstalledHook {
scope: scope.to_string(),
project: project.to_string(),
event: event_str,
hook_type: hook_type_str,
provider: provider_str,
skill_providers: skill_providers.iter().map(|s| s.to_string()).collect(),
provider_args: provider_args_str,
});
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(raw) = toml::to_string_pretty(&file) {
let _ = std::fs::write(&path, raw);
}
}
pub(crate) fn add_skill_provider_to_hook(
scope: &str,
project: &str,
event: &HookEvent,
skill_provider: &str,
) {
let path = match installed_hooks_path() {
Some(p) => p,
None => return,
};
let mut file = load_installed_hooks();
let event_str = event.as_str();
let existing = file.hooks.iter_mut().find(|h| {
h.scope == scope && h.project == project && h.event == event_str && h.hook_type == "agent"
});
if let Some(entry) = existing {
let sp = skill_provider.to_string();
if !entry.skill_providers.contains(&sp) {
entry.skill_providers.push(sp);
}
} else {
file.hooks.push(InstalledHook {
scope: scope.to_string(),
project: project.to_string(),
event: event_str.to_string(),
hook_type: "agent".to_string(),
provider: String::new(),
skill_providers: vec![skill_provider.to_string()],
provider_args: String::new(),
});
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(raw) = toml::to_string_pretty(&file) {
let _ = std::fs::write(&path, raw);
}
}
pub(crate) fn remove_installed_hook(scope: &str, project: &str, event: &HookEvent) {
let path = match installed_hooks_path() {
Some(p) => p,
None => return,
};
let mut file = load_installed_hooks();
let event_str = event.as_str();
file.hooks
.retain(|h| !(h.scope == scope && h.project == project && h.event == event_str));
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(raw) = toml::to_string_pretty(&file) {
let _ = std::fs::write(&path, raw);
}
}
pub(crate) fn remove_skill_provider_from_hook(
scope: &str,
project: &str,
event: &HookEvent,
skill_provider: &str,
) {
let path = match installed_hooks_path() {
Some(p) => p,
None => return,
};
let mut file = load_installed_hooks();
let event_str = event.as_str();
let existing = file.hooks.iter_mut().find(|h| {
h.scope == scope && h.project == project && h.event == event_str && h.hook_type == "agent"
});
if let Some(entry) = existing {
entry.skill_providers.retain(|sp| sp != skill_provider);
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(raw) = toml::to_string_pretty(&file) {
let _ = std::fs::write(&path, raw);
}
}
pub(crate) fn deduplicate_hook_types(types: Vec<HookTool>) -> Vec<HookTool> {
let mut result: Vec<HookTool> = Vec::new();
for t in types {
if result
.iter()
.any(|r| std::mem::discriminant(r) == std::mem::discriminant(&t))
{
continue;
}
let base_already_upgraded = match &t {
HookTool::Git => result.iter().any(|r| matches!(r, HookTool::GitWithAgent)),
HookTool::Prek => result.iter().any(|r| matches!(r, HookTool::PrekWithAgent)),
_ => false,
};
if base_already_upgraded {
continue;
}
match &t {
HookTool::GitWithAgent => result.retain(|r| !matches!(r, HookTool::Git)),
HookTool::PrekWithAgent => result.retain(|r| !matches!(r, HookTool::Prek)),
_ => {}
}
result.push(t);
}
result
}
pub(crate) fn deduplicate_hook_events(events: Vec<HookEvent>) -> Vec<HookEvent> {
let mut seen = std::collections::HashSet::new();
events
.into_iter()
.filter(|e| seen.insert(std::mem::discriminant(e)))
.collect()
}
pub(crate) fn apply_yes_fallback(
types: Vec<HookTool>,
events: Vec<HookEvent>,
) -> (Vec<HookTool>, Vec<HookEvent>) {
let resolved_types = if types.is_empty() {
vec![HookTool::Git]
} else {
types
};
let resolved_events = if events.is_empty() {
let agent_only = resolved_types.len() == 1 && matches!(resolved_types[0], HookTool::Agent);
if agent_only {
vec![
HookEvent::PreCommit,
HookEvent::CommitMsg,
HookEvent::PrePush,
]
} else {
vec![HookEvent::PreCommit]
}
} else {
events
};
(resolved_types, resolved_events)
}