use std::path::{Path, PathBuf};
use fairlead_guard::lefthook;
use serde_json::{Map, Value};
use super::{
add_ours, agent_file, git_run, installed, is_ours, lefthook_file, lefthook_manifest,
package_runner, parse, pretty, read, read_manifest, stages, strip, write, Agent, EVENTS,
SESSION_EVENT,
};
pub struct Refresh {
pub file: PathBuf,
manifest: PathBuf,
before: String,
pub after: String,
pub what: String,
}
fn ours(settings: &Map<String, Value>) -> Vec<(String, String, String, u64)> {
let mut out = Vec::new();
let Some(hooks) = settings.get("hooks").and_then(Value::as_object) else {
return out;
};
for event in EVENTS.concat().into_iter().chain([SESSION_EVENT]) {
for group in hooks
.get(event)
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let matcher = group.get("matcher").and_then(Value::as_str).unwrap_or("");
for hook in group
.get("hooks")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if is_ours(hook) {
out.push((
event.to_string(),
matcher.to_string(),
hook.get("command")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
hook.get("timeout").and_then(Value::as_u64).unwrap_or(0),
));
}
}
}
}
out
}
fn difference(
had: &[(String, String, String, u64)],
want: &[(String, String, String, u64)],
) -> String {
let name = |e: &(String, String, String, u64)| match e.0.as_str() {
"PreToolUse" | "BeforeTool" if e.1 == "Bash" || e.1 == "run_shell_command" => {
"the shell-command guard".to_string()
}
"PreToolUse" | "BeforeTool" => "the edit guard".to_string(),
"PostToolUse" | "AfterTool" => "the brief nudge".to_string(),
SESSION_EVENT => "the session resume".to_string(),
_ => "the Stop hook".to_string(),
};
let names = |v: &[(String, String, String, u64)]| v.iter().map(name).collect::<Vec<_>>();
let (had_n, want_n) = (names(had), names(want));
let added: Vec<_> = want_n
.iter()
.filter(|n| !had_n.contains(n))
.cloned()
.collect();
let dropped: Vec<_> = had_n
.iter()
.filter(|n| !want_n.contains(n))
.cloned()
.collect();
let mut parts = Vec::new();
if !added.is_empty() {
parts.push(format!("adds {}", added.join(" and ")));
}
if !dropped.is_empty() {
parts.push(format!("drops {}", dropped.join(" and ")));
}
for w in want {
if let Some(h) = had.iter().find(|h| name(h) == name(w) && h.1 != w.1) {
parts.push(format!(
"{} matches `{}` instead of `{}`",
name(w),
w.1,
h.1
));
}
}
if parts.is_empty() {
parts.push("updates the hooks' commands".into());
}
parts.join(", ")
}
pub fn stale_agents(
root: &Path,
git_dir: &Path,
config: &fairlead_core::config::Config,
) -> Result<Vec<Refresh>, String> {
let mut out = Vec::new();
for (agent, name) in [
(Agent::Claude, "settings.json"),
(Agent::Claude, "settings.local.json"),
(Agent::Codex, ""),
(Agent::Gemini, ""),
] {
let (file, manifest) = agent_file(root, git_dir, agent, name);
let Some(text) = read(&file)? else { continue };
let settings = parse(&file, &text)?;
if installed(&settings).is_empty() {
continue;
}
let mut want = settings.clone();
strip(&mut want);
add_ours(
&mut want,
&file,
&stages(config),
package_runner(root),
agent,
)?;
let (had, wanted) = (ours(&settings), ours(&want));
if had == wanted {
continue;
}
out.push(Refresh {
what: difference(&had, &wanted),
manifest,
file,
before: text,
after: pretty(&want),
});
}
Ok(out)
}
pub fn claude_installed(root: &Path) -> bool {
["settings.json", "settings.local.json"].iter().any(|name| {
let file = root.join(".claude").join(name);
read(&file)
.ok()
.flatten()
.and_then(|text| parse(&file, &text).ok())
.is_some_and(|settings| !installed(&settings).is_empty())
})
}
pub fn stale_git(root: &Path, git_dir: &Path) -> Result<Option<Refresh>, String> {
let file = lefthook_file(root);
let Some(text) = read(&file)? else {
return Ok(None);
};
let want = git_run(package_runner(root));
match lefthook::current_run(&text) {
Some(run) if run != want => {
let after = lefthook::set_run(&text, &want).expect("the stage line was found");
Ok(Some(Refresh {
what: format!("runs `{want}` instead of `{run}`"),
manifest: lefthook_manifest(git_dir, &file),
file,
before: text,
after,
}))
}
_ => Ok(None),
}
}
pub fn apply(refresh: &Refresh) -> Result<(), String> {
write(&refresh.file, &refresh.after)?;
if let Some(mut record) = read_manifest(&refresh.manifest)? {
if record.written == refresh.before {
record.written = refresh.after.clone();
write(
&refresh.manifest,
&serde_json::to_string_pretty(&record).expect("manifest serializes"),
)?;
}
}
Ok(())
}