1use crate::journal::{ActionId, Journal, JournalError, ManifestRole, NewAction};
15use crate::registry::Registry;
16use crate::resolver::{Ctx, Severity, resolve};
17use crate::snapshot::{Limits, SnapshotError, Store};
18use std::path::PathBuf;
19
20#[derive(Debug, thiserror::Error)]
21pub enum HookError {
22 #[error("event parse: {0}")]
23 Parse(String),
24 #[error("event is for tool {0}, not Bash")]
25 NotBash(String),
26 #[error(transparent)]
27 Journal(#[from] JournalError),
28 #[error(transparent)]
29 Snapshot(#[from] SnapshotError),
30 #[error("registry: {0}")]
31 Registry(#[from] crate::registry::RegistryError),
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum UnknownPolicy {
36 SnapshotCwd,
39 Passthrough,
41}
42
43const DEFAULT_SNAPSHOT_MS: u64 = 5_000;
48
49fn snapshot_budget() -> Option<std::time::Duration> {
50 parse_snapshot_budget(std::env::var("DOOVER_MAX_SNAPSHOT_MS").ok().as_deref())
51}
52
53pub fn effective_snapshot_budget() -> Option<std::time::Duration> {
58 snapshot_budget()
59}
60
61fn parse_snapshot_budget(v: Option<&str>) -> Option<std::time::Duration> {
65 let default = std::time::Duration::from_millis(DEFAULT_SNAPSHOT_MS);
66 match v {
67 None => Some(default),
68 Some(s) => match s.trim().parse::<u64>() {
69 Ok(0) => None,
70 Ok(ms) => Some(std::time::Duration::from_millis(ms)),
71 Err(_) => Some(default),
72 },
73 }
74}
75
76const DEFAULT_SKIP_DIRS: &[&str] = &[
83 "target", "node_modules", "__pycache__",
86 ".venv",
87 "venv",
88 ".mypy_cache",
89 ".pytest_cache",
90 ".tox",
91 "dist",
92 "build",
93 ".next",
94 ".nuxt",
95 ".svelte-kit",
96 ".gradle",
97 ".terraform",
98 ".cargo",
99 "vendor", ".parcel-cache",
101 ".turbo",
102];
103
104pub fn skip_dir_names() -> Vec<String> {
107 match std::env::var("DOOVER_SKIP_DIRS") {
108 Ok(v) => v
109 .split(',')
110 .map(|s| s.trim())
111 .filter(|s| !s.is_empty())
112 .map(String::from)
113 .collect(),
114 Err(_) => DEFAULT_SKIP_DIRS.iter().map(|s| s.to_string()).collect(),
115 }
116}
117
118pub fn skip_policy_for(path: &std::path::Path) -> crate::snapshot::SkipPolicy {
123 let repo = crate::resolver::find_repo_root(path);
124 crate::snapshot::SkipPolicy::new(skip_dir_names(), repo.as_deref())
125}
126
127pub fn ensure_private_home(path: &std::path::Path) -> std::io::Result<()> {
133 std::fs::create_dir_all(path)?;
134 #[cfg(unix)]
135 {
136 use std::os::unix::fs::PermissionsExt;
137 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
138 }
139 Ok(())
140}
141
142pub struct HookConfig {
143 pub doover_home: PathBuf,
145 pub home: PathBuf,
147 pub limits: Limits,
151 pub unknown_policy: UnknownPolicy,
152 pub maintenance: crate::maintenance::MaintenanceBudget,
156}
157
158impl HookConfig {
159 pub fn from_env() -> Self {
162 let home = std::env::var_os("HOME")
163 .map(PathBuf::from)
164 .unwrap_or_else(|| PathBuf::from("/"));
165 let doover_home = std::env::var_os("DOOVER_HOME")
166 .map(PathBuf::from)
167 .unwrap_or_else(|| home.join(".doover"));
168 let env_u64 = |k: &str, default: u64| {
169 std::env::var(k)
170 .ok()
171 .and_then(|v| v.parse().ok())
172 .unwrap_or(default)
173 };
174 let unknown_policy = match std::env::var("DOOVER_UNKNOWN_POLICY").as_deref() {
175 Ok("passthrough") => UnknownPolicy::Passthrough,
176 _ => UnknownPolicy::SnapshotCwd,
177 };
178 Self {
179 doover_home,
180 home,
181 limits: Limits {
182 max_files: env_u64("DOOVER_MAX_FILES", 100_000),
183 max_bytes: env_u64("DOOVER_MAX_BYTES", 5 * 1024 * 1024 * 1024),
184 max_duration: snapshot_budget(),
185 },
186 unknown_policy,
187 maintenance: crate::maintenance::MaintenanceBudget::from_env(),
188 }
189 }
190}
191
192#[derive(Debug)]
193pub struct PreEvent {
194 pub session_id: String,
195 pub tool_use_id: String,
196 pub tool_name: String,
197 pub cwd: PathBuf,
198 pub command: String,
199}
200
201#[derive(Debug)]
202pub struct PostEvent {
203 pub session_id: String,
204 pub tool_use_id: String,
205 pub tool_name: String,
206 pub duration_ms: i64,
207}
208
209mod wire {
210 #[derive(serde::Deserialize)]
211 pub struct ToolInput {
212 pub command: Option<String>,
213 }
214 #[derive(serde::Deserialize)]
215 pub struct Event {
216 pub session_id: String,
217 pub cwd: String,
218 pub tool_name: String,
219 pub tool_use_id: String,
220 pub tool_input: Option<ToolInput>,
221 pub duration_ms: Option<i64>,
222 }
223}
224
225fn slice_limits(base: &Limits, deadline: Option<std::time::Instant>) -> Limits {
233 let mut l = *base;
234 if let Some(dl) = deadline {
235 l.max_duration = Some(dl.saturating_duration_since(std::time::Instant::now()));
236 }
237 l
238}
239
240fn hook_deadline(limits: &Limits) -> Option<std::time::Instant> {
241 limits.max_duration.map(|d| std::time::Instant::now() + d)
242}
243
244pub fn parse_pre_event(json: &str) -> Result<PreEvent, HookError> {
245 let e: wire::Event = serde_json::from_str(json).map_err(|e| HookError::Parse(e.to_string()))?;
246 if e.tool_name != "Bash" {
247 return Err(HookError::NotBash(e.tool_name));
248 }
249 let command = e
250 .tool_input
251 .and_then(|t| t.command)
252 .ok_or_else(|| HookError::Parse("missing tool_input.command".into()))?;
253 Ok(PreEvent {
254 session_id: e.session_id,
255 tool_use_id: e.tool_use_id,
256 tool_name: "Bash".into(),
257 cwd: PathBuf::from(e.cwd),
258 command,
259 })
260}
261
262pub fn parse_post_event(json: &str) -> Result<PostEvent, HookError> {
263 let e: wire::Event = serde_json::from_str(json).map_err(|e| HookError::Parse(e.to_string()))?;
264 if e.tool_name != "Bash" {
265 return Err(HookError::NotBash(e.tool_name));
266 }
267 Ok(PostEvent {
268 session_id: e.session_id,
269 tool_use_id: e.tool_use_id,
270 tool_name: "Bash".into(),
271 duration_ms: e.duration_ms.unwrap_or(0),
273 })
274}
275
276pub struct PreOutcome {
282 pub action_id: ActionId,
283 pub manifests_attached: usize,
284 pub severity: Severity,
285 pub gaps: Vec<String>,
286}
287
288impl PreOutcome {
289 pub fn needs_warning(&self) -> bool {
298 !self.gaps.is_empty()
299 }
300}
301
302fn open_journal(cfg: &HookConfig) -> Result<Journal, HookError> {
303 ensure_private_home(&cfg.doover_home).map_err(|e| {
304 HookError::Parse(format!(
305 "cannot create doover home {}: {e}",
306 cfg.doover_home.display()
307 ))
308 })?;
309 Ok(Journal::open(&cfg.doover_home.join("journal.db"))?)
310}
311
312pub fn handle_pre(cfg: &HookConfig, ev: &PreEvent) -> Result<PreOutcome, HookError> {
313 let journal = open_journal(cfg)?;
314 journal.begin_session(&ev.session_id, "claude-code", &ev.cwd.to_string_lossy())?;
315
316 let (registry, overlay_warnings) = Registry::with_overlay(&cfg.doover_home.join("registry.d"))?;
317 for w in &overlay_warnings {
318 eprintln!("doover: registry overlay: {w}");
319 }
320
321 let ctx = Ctx {
322 cwd: &ev.cwd,
323 home: &cfg.home,
324 };
325 let r = resolve(&ev.command, ®istry, &ctx);
326
327 let action = journal.start_action(&NewAction {
328 session_id: &ev.session_id,
329 tool_use_id: Some(&ev.tool_use_id),
330 raw_command: &ev.command,
331 effect: r.severity.as_str(),
332 rule_id: r.rule_id.as_deref(),
333 has_unknown: r.has_unknown,
334 })?;
335
336 let mut targets: Vec<PathBuf> = Vec::new();
339 if r.severity >= Severity::Destructive {
340 targets.extend(r.paths.iter().cloned());
341 }
342 if r.has_unknown && cfg.unknown_policy == UnknownPolicy::SnapshotCwd {
343 let cwd = crate::resolver::normalize_lexical(&ev.cwd);
344 if !targets.contains(&cwd) {
345 targets.push(cwd);
346 }
347 }
348
349 let mut attached = 0usize;
350 let mut gaps: Vec<String> = Vec::new();
351 let mut note_gap = |journal: &Journal, msg: String| -> Result<(), HookError> {
354 journal.add_note(action, &msg)?;
355 gaps.push(msg);
356 Ok(())
357 };
358 if !targets.is_empty() {
359 let store = Store::open(cfg.doover_home.join("store"))?;
360 let deadline = hook_deadline(&cfg.limits);
361 for path in &targets {
362 let skips = skip_policy_for(path);
363 match store.snapshot_scoped(
366 path,
367 Some(&slice_limits(&cfg.limits, deadline)),
368 std::slice::from_ref(&cfg.doover_home),
369 &skips,
370 ) {
371 Ok(manifest) => {
372 if manifest.truncated {
373 note_gap(
374 &journal,
375 format!(
376 "UNPROTECTED: snapshot of {} truncated at limits ({} files skipped)",
377 path.display(),
378 manifest.skipped
379 ),
380 )?;
381 }
382 if !manifest.warnings.is_empty() {
383 note_gap(
384 &journal,
385 format!(
386 "PARTIAL: snapshot gaps at {}: {}",
387 path.display(),
388 manifest.warnings.join("; ")
389 ),
390 )?;
391 }
392 journal.attach_manifest(action, &manifest, ManifestRole::Pre)?;
393 attached += 1;
394 }
395 Err(e) => {
396 note_gap(
397 &journal,
398 format!("UNPROTECTED: snapshot of {} failed: {e}", path.display()),
399 )?;
400 }
401 }
402 }
403 }
404
405 Ok(PreOutcome {
406 action_id: action,
407 manifests_attached: attached,
408 severity: r.severity,
409 gaps,
410 })
411}
412
413pub fn handle_post(cfg: &HookConfig, ev: &PostEvent) -> Result<ActionId, HookError> {
414 let journal = open_journal(cfg)?;
415 let action = journal.complete_by_tool_use(&ev.session_id, &ev.tool_use_id, ev.duration_ms)?;
416
417 let pre = journal.manifests_by_role(action, ManifestRole::Pre)?;
422 if !pre.is_empty() {
423 let store = Store::open(cfg.doover_home.join("store"))?;
424 let deadline = hook_deadline(&cfg.limits);
425 for m in &pre {
426 let skips = skip_policy_for(&m.path);
427 match store.snapshot_scoped(
428 &m.path,
429 Some(&slice_limits(&cfg.limits, deadline)),
430 std::slice::from_ref(&cfg.doover_home),
431 &skips,
432 ) {
433 Ok(post) => journal.attach_manifest(action, &post, ManifestRole::Post)?,
434 Err(e) => journal.add_note(
435 action,
436 &format!(
437 "post-state snapshot of {} failed: {e} (redo/conflict checks unavailable)",
438 m.path.display()
439 ),
440 )?,
441 }
442 }
443 }
444 maybe_gc(cfg, &journal, action);
445 Ok(action)
446}
447
448const FREE_LOW_RETRIGGER_SECS: u64 = 600;
452
453fn maybe_gc(cfg: &HookConfig, journal: &Journal, action: ActionId) {
467 let b = cfg.maintenance;
468 if b.gc_every == 0 {
469 return; }
471 let cadence_due = action % (b.gc_every as i64).max(1) == 0;
472 let free_low = b
473 .min_free_bytes
474 .zip(crate::snapshot::free_bytes(&cfg.doover_home))
475 .is_some_and(|(floor, free)| free < floor);
476 let free_low_due = free_low && free_low_retrigger_elapsed(cfg);
477 if !cadence_due && !free_low_due {
478 return;
479 }
480 let Ok(store) = Store::open(cfg.doover_home.join("store")) else {
481 return;
482 };
483 let report = crate::maintenance::gc(
484 journal,
485 &store,
486 &cfg.doover_home,
487 &crate::maintenance::auto_gc_options(&b),
488 );
489 touch_gc_marker(cfg);
490 let Ok(report) = report else { return };
491
492 if report.cap_evicted_actions > 0 || report.still_over_budget {
494 let note = format!(
495 "auto-gc: evicted {} old action(s) to satisfy the store size cap{}",
496 report.cap_evicted_actions,
497 if report.still_over_budget {
498 "; STILL over budget (bounded by pins/recent actions — run `doover gc`)"
499 } else {
500 ""
501 }
502 );
503 let _ = journal.add_note(action, ¬e);
504 eprintln!("doover: {note}");
505 }
506 if free_low {
507 eprintln!(
508 "doover: disk space is low (below the DOOVER_MIN_FREE_BYTES floor); \
509 doover's store is bounded by its size cap — run `doover gc` to review \
510 or evict more history"
511 );
512 }
513}
514
515fn free_low_retrigger_elapsed(cfg: &HookConfig) -> bool {
519 let marker = cfg.doover_home.join(".last-auto-gc");
520 match std::fs::metadata(&marker).and_then(|m| m.modified()) {
521 Ok(at) => std::time::SystemTime::now()
522 .duration_since(at)
523 .map(|age| age.as_secs() >= FREE_LOW_RETRIGGER_SECS)
524 .unwrap_or(true),
525 Err(_) => true,
526 }
527}
528
529fn touch_gc_marker(cfg: &HookConfig) {
530 let _ = std::fs::write(cfg.doover_home.join(".last-auto-gc"), b"");
531}
532
533#[cfg(test)]
534mod budget_tests {
535 use super::{DEFAULT_SNAPSHOT_MS, parse_snapshot_budget};
536 use std::time::Duration;
537
538 #[test]
539 fn budget_parse_is_fail_safe() {
540 let default = Some(Duration::from_millis(DEFAULT_SNAPSHOT_MS));
541 assert_eq!(parse_snapshot_budget(None), default);
543 assert_eq!(parse_snapshot_budget(Some("nonsense")), default);
544 assert_eq!(parse_snapshot_budget(Some("-5")), default);
545 assert_eq!(parse_snapshot_budget(Some("")), default);
546 assert_eq!(parse_snapshot_budget(Some("0")), None);
548 assert_eq!(
550 parse_snapshot_budget(Some("2500")),
551 Some(Duration::from_millis(2500))
552 );
553 assert_eq!(
554 parse_snapshot_budget(Some(" 8000 ")),
555 Some(Duration::from_millis(8000))
556 );
557 }
558}