use crate::journal::{ActionId, Journal, JournalError, ManifestRole, NewAction};
use crate::registry::Registry;
use crate::resolver::{Ctx, Severity, resolve};
use crate::snapshot::{Limits, SnapshotError, Store};
use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
pub enum HookError {
#[error("event parse: {0}")]
Parse(String),
#[error("event is for tool {0}, not Bash")]
NotBash(String),
#[error(transparent)]
Journal(#[from] JournalError),
#[error(transparent)]
Snapshot(#[from] SnapshotError),
#[error("registry: {0}")]
Registry(#[from] crate::registry::RegistryError),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnknownPolicy {
SnapshotCwd,
Passthrough,
}
const DEFAULT_SNAPSHOT_MS: u64 = 5_000;
fn snapshot_budget() -> Option<std::time::Duration> {
parse_snapshot_budget(std::env::var("DOOVER_MAX_SNAPSHOT_MS").ok().as_deref())
}
pub fn effective_snapshot_budget() -> Option<std::time::Duration> {
snapshot_budget()
}
fn parse_snapshot_budget(v: Option<&str>) -> Option<std::time::Duration> {
let default = std::time::Duration::from_millis(DEFAULT_SNAPSHOT_MS);
match v {
None => Some(default),
Some(s) => match s.trim().parse::<u64>() {
Ok(0) => None,
Ok(ms) => Some(std::time::Duration::from_millis(ms)),
Err(_) => Some(default),
},
}
}
pub fn ensure_private_home(path: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
pub struct HookConfig {
pub doover_home: PathBuf,
pub home: PathBuf,
pub limits: Limits,
pub unknown_policy: UnknownPolicy,
pub maintenance: crate::maintenance::MaintenanceBudget,
}
impl HookConfig {
pub fn from_env() -> Self {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/"));
let doover_home = std::env::var_os("DOOVER_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".doover"));
let env_u64 = |k: &str, default: u64| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
};
let unknown_policy = match std::env::var("DOOVER_UNKNOWN_POLICY").as_deref() {
Ok("passthrough") => UnknownPolicy::Passthrough,
_ => UnknownPolicy::SnapshotCwd,
};
Self {
doover_home,
home,
limits: Limits {
max_files: env_u64("DOOVER_MAX_FILES", 100_000),
max_bytes: env_u64("DOOVER_MAX_BYTES", 5 * 1024 * 1024 * 1024),
max_duration: snapshot_budget(),
},
unknown_policy,
maintenance: crate::maintenance::MaintenanceBudget::from_env(),
}
}
}
#[derive(Debug)]
pub struct PreEvent {
pub session_id: String,
pub tool_use_id: String,
pub tool_name: String,
pub cwd: PathBuf,
pub command: String,
}
#[derive(Debug)]
pub struct PostEvent {
pub session_id: String,
pub tool_use_id: String,
pub tool_name: String,
pub duration_ms: i64,
}
mod wire {
#[derive(serde::Deserialize)]
pub struct ToolInput {
pub command: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct Event {
pub session_id: String,
pub cwd: String,
pub tool_name: String,
pub tool_use_id: String,
pub tool_input: Option<ToolInput>,
pub duration_ms: Option<i64>,
}
}
fn slice_limits(base: &Limits, deadline: Option<std::time::Instant>) -> Limits {
let mut l = *base;
if let Some(dl) = deadline {
l.max_duration = Some(dl.saturating_duration_since(std::time::Instant::now()));
}
l
}
fn hook_deadline(limits: &Limits) -> Option<std::time::Instant> {
limits.max_duration.map(|d| std::time::Instant::now() + d)
}
pub fn parse_pre_event(json: &str) -> Result<PreEvent, HookError> {
let e: wire::Event = serde_json::from_str(json).map_err(|e| HookError::Parse(e.to_string()))?;
if e.tool_name != "Bash" {
return Err(HookError::NotBash(e.tool_name));
}
let command = e
.tool_input
.and_then(|t| t.command)
.ok_or_else(|| HookError::Parse("missing tool_input.command".into()))?;
Ok(PreEvent {
session_id: e.session_id,
tool_use_id: e.tool_use_id,
tool_name: "Bash".into(),
cwd: PathBuf::from(e.cwd),
command,
})
}
pub fn parse_post_event(json: &str) -> Result<PostEvent, HookError> {
let e: wire::Event = serde_json::from_str(json).map_err(|e| HookError::Parse(e.to_string()))?;
if e.tool_name != "Bash" {
return Err(HookError::NotBash(e.tool_name));
}
Ok(PostEvent {
session_id: e.session_id,
tool_use_id: e.tool_use_id,
tool_name: "Bash".into(),
duration_ms: e.duration_ms.unwrap_or(0),
})
}
pub struct PreOutcome {
pub action_id: ActionId,
pub manifests_attached: usize,
pub severity: Severity,
pub gaps: Vec<String>,
}
impl PreOutcome {
pub fn needs_warning(&self) -> bool {
!self.gaps.is_empty()
}
}
fn open_journal(cfg: &HookConfig) -> Result<Journal, HookError> {
ensure_private_home(&cfg.doover_home).map_err(|e| {
HookError::Parse(format!(
"cannot create doover home {}: {e}",
cfg.doover_home.display()
))
})?;
Ok(Journal::open(&cfg.doover_home.join("journal.db"))?)
}
pub fn handle_pre(cfg: &HookConfig, ev: &PreEvent) -> Result<PreOutcome, HookError> {
let journal = open_journal(cfg)?;
journal.begin_session(&ev.session_id, "claude-code", &ev.cwd.to_string_lossy())?;
let (registry, overlay_warnings) = Registry::with_overlay(&cfg.doover_home.join("registry.d"))?;
for w in &overlay_warnings {
eprintln!("doover: registry overlay: {w}");
}
let ctx = Ctx {
cwd: &ev.cwd,
home: &cfg.home,
};
let r = resolve(&ev.command, ®istry, &ctx);
let action = journal.start_action(&NewAction {
session_id: &ev.session_id,
tool_use_id: Some(&ev.tool_use_id),
raw_command: &ev.command,
effect: r.severity.as_str(),
rule_id: r.rule_id.as_deref(),
has_unknown: r.has_unknown,
})?;
let mut targets: Vec<PathBuf> = Vec::new();
if r.severity >= Severity::Destructive {
targets.extend(r.paths.iter().cloned());
}
if r.has_unknown && cfg.unknown_policy == UnknownPolicy::SnapshotCwd {
let cwd = crate::resolver::normalize_lexical(&ev.cwd);
if !targets.contains(&cwd) {
targets.push(cwd);
}
}
let mut attached = 0usize;
let mut gaps: Vec<String> = Vec::new();
let mut note_gap = |journal: &Journal, msg: String| -> Result<(), HookError> {
journal.add_note(action, &msg)?;
gaps.push(msg);
Ok(())
};
if !targets.is_empty() {
let store = Store::open(cfg.doover_home.join("store"))?;
let deadline = hook_deadline(&cfg.limits);
for path in &targets {
match store.snapshot_excluding(
path,
Some(&slice_limits(&cfg.limits, deadline)),
std::slice::from_ref(&cfg.doover_home),
) {
Ok(manifest) => {
if manifest.truncated {
note_gap(
&journal,
format!(
"UNPROTECTED: snapshot of {} truncated at limits ({} files skipped)",
path.display(),
manifest.skipped
),
)?;
}
if !manifest.warnings.is_empty() {
note_gap(
&journal,
format!(
"PARTIAL: snapshot gaps at {}: {}",
path.display(),
manifest.warnings.join("; ")
),
)?;
}
journal.attach_manifest(action, &manifest, ManifestRole::Pre)?;
attached += 1;
}
Err(e) => {
note_gap(
&journal,
format!("UNPROTECTED: snapshot of {} failed: {e}", path.display()),
)?;
}
}
}
}
Ok(PreOutcome {
action_id: action,
manifests_attached: attached,
severity: r.severity,
gaps,
})
}
pub fn handle_post(cfg: &HookConfig, ev: &PostEvent) -> Result<ActionId, HookError> {
let journal = open_journal(cfg)?;
let action = journal.complete_by_tool_use(&ev.session_id, &ev.tool_use_id, ev.duration_ms)?;
let pre = journal.manifests_by_role(action, ManifestRole::Pre)?;
if !pre.is_empty() {
let store = Store::open(cfg.doover_home.join("store"))?;
let deadline = hook_deadline(&cfg.limits);
for m in &pre {
match store.snapshot_excluding(
&m.path,
Some(&slice_limits(&cfg.limits, deadline)),
std::slice::from_ref(&cfg.doover_home),
) {
Ok(post) => journal.attach_manifest(action, &post, ManifestRole::Post)?,
Err(e) => journal.add_note(
action,
&format!(
"post-state snapshot of {} failed: {e} (redo/conflict checks unavailable)",
m.path.display()
),
)?,
}
}
}
maybe_gc(cfg, &journal, action);
Ok(action)
}
const FREE_LOW_RETRIGGER_SECS: u64 = 600;
fn maybe_gc(cfg: &HookConfig, journal: &Journal, action: ActionId) {
let b = cfg.maintenance;
if b.gc_every == 0 {
return; }
let cadence_due = action % (b.gc_every as i64).max(1) == 0;
let free_low = b
.min_free_bytes
.zip(crate::snapshot::free_bytes(&cfg.doover_home))
.is_some_and(|(floor, free)| free < floor);
let free_low_due = free_low && free_low_retrigger_elapsed(cfg);
if !cadence_due && !free_low_due {
return;
}
let Ok(store) = Store::open(cfg.doover_home.join("store")) else {
return;
};
let report = crate::maintenance::gc(
journal,
&store,
&cfg.doover_home,
&crate::maintenance::auto_gc_options(&b),
);
touch_gc_marker(cfg);
let Ok(report) = report else { return };
if report.cap_evicted_actions > 0 || report.still_over_budget {
let note = format!(
"auto-gc: evicted {} old action(s) to satisfy the store size cap{}",
report.cap_evicted_actions,
if report.still_over_budget {
"; STILL over budget (bounded by pins/recent actions — run `doover gc`)"
} else {
""
}
);
let _ = journal.add_note(action, ¬e);
eprintln!("doover: {note}");
}
if free_low {
eprintln!(
"doover: disk space is low (below the DOOVER_MIN_FREE_BYTES floor); \
doover's store is bounded by its size cap — run `doover gc` to review \
or evict more history"
);
}
}
fn free_low_retrigger_elapsed(cfg: &HookConfig) -> bool {
let marker = cfg.doover_home.join(".last-auto-gc");
match std::fs::metadata(&marker).and_then(|m| m.modified()) {
Ok(at) => std::time::SystemTime::now()
.duration_since(at)
.map(|age| age.as_secs() >= FREE_LOW_RETRIGGER_SECS)
.unwrap_or(true),
Err(_) => true,
}
}
fn touch_gc_marker(cfg: &HookConfig) {
let _ = std::fs::write(cfg.doover_home.join(".last-auto-gc"), b"");
}
#[cfg(test)]
mod budget_tests {
use super::{DEFAULT_SNAPSHOT_MS, parse_snapshot_budget};
use std::time::Duration;
#[test]
fn budget_parse_is_fail_safe() {
let default = Some(Duration::from_millis(DEFAULT_SNAPSHOT_MS));
assert_eq!(parse_snapshot_budget(None), default);
assert_eq!(parse_snapshot_budget(Some("nonsense")), default);
assert_eq!(parse_snapshot_budget(Some("-5")), default);
assert_eq!(parse_snapshot_budget(Some("")), default);
assert_eq!(parse_snapshot_budget(Some("0")), None);
assert_eq!(
parse_snapshot_budget(Some("2500")),
Some(Duration::from_millis(2500))
);
assert_eq!(
parse_snapshot_budget(Some(" 8000 ")),
Some(Duration::from_millis(8000))
);
}
}