difflore_cli/hooks/session_banner/mod.rs
1//! Since-last-session banner.
2//!
3//! Emits a short, agent-visible note when the current repo has gained
4//! rules since the last `SessionStart` fired for it. Plugs into every
5//! platform adapter (Claude Code, Cursor, Gemini CLI, Windsurf) via the
6//! `additional_context` field on `HookResult` — adapters just append the
7//! banner string to whatever context they already produce.
8//!
9//! Design notes
10//! ────────────
11//! * **Hot path discipline.** The helper runs inside `SessionStart`
12//! dispatch, so total wall time is budgeted to 50ms p99. Any DB read
13//! that crosses that budget bails out via `tokio::time::timeout` and
14//! the caller sees `None` — never a panic, never an error bubble.
15//! * **Zero-noise on quiet sessions.** When no new rules exist for this
16//! repo since the last fire, the helper returns `None`. The adapter
17//! then appends nothing, preserving the exact existing transcript.
18//! * **Per-repo watermark.** Each repo gets its own JSON watermark at
19//! `~/.difflore/projects/{hash}/last-session-start.json`. New repos
20//! start with `prev_ts = None`, so the first session shows everything
21//! learned to date (capped at 5). The watermark is then advanced to
22//! "now" so subsequent fires only show genuinely-new rules.
23//! * **DB read failures are swallowed.** Hooks must never block the
24//! agent. If `data.db` is locked, missing, or rejects the query, the
25//! helper returns `None`.
26
27pub mod query;
28pub mod render;
29pub mod watermark;
30
31#[cfg(test)]
32mod tests;
33
34use std::time::Duration;
35
36/// What the banner helper needs to do its job. The integration site in
37/// `hook_runtime::dispatch` builds this from the `HookEvent::SessionStart`
38/// payload it already has — no new plumbing through the rest of the
39/// pipeline. `client_name` is recorded into the watermark so a future
40/// follow-up can per-client-scope the "since last session" wording (e.g.
41/// "since last Claude Code session"); the current banner just uses it
42/// for the watermark file's debug trail.
43#[derive(Debug, Clone)]
44pub struct BannerContext {
45 /// Absolute path to the agent's working directory. Used to resolve
46 /// the project root → project hash → watermark file, and to detect
47 /// the current GitHub repo aliases that filter the query.
48 pub cwd: String,
49 /// Platform adapter name (`"claude-code"`, `"cursor"`, …). Stored
50 /// in the watermark JSON for debug only; the query treats every
51 /// client identically.
52 pub client_name: String,
53}
54
55/// Wall-clock budget for the entire banner pipeline. p99 ceiling — if
56/// the DB read or watermark IO exceeds this, the helper returns `None`
57/// so the adapter doesn't stall the agent on a slow disk.
58const BANNER_BUDGET: Duration = Duration::from_millis(50);
59
60/// Max rules listed inside the banner. Above this we'd push the banner
61/// past the 6-line / 400-char shape the spec calls for, and the agent's
62/// context window would notice. Most repos accumulate <5 new rules
63/// between sessions anyway.
64const MAX_RULES_IN_BANNER: usize = 5;
65
66/// Build the "since-last-session" banner for the given context. Returns
67/// `None` when there is nothing new to show OR when any step in the
68/// pipeline failed — callers should treat both as "emit nothing".
69///
70/// The pipeline:
71/// 1. Resolve the project root + repo aliases from `ctx.cwd`.
72/// 2. Read the watermark (`prev_ts`) — `None` on first session.
73/// 3. Open `data.db` and query for rules with `installed_at > prev_ts`
74/// whose `source_repo` matches one of this repo's aliases.
75/// 4. Advance the watermark to "now" (best-effort).
76/// 5. Format the rows into the banner string.
77///
78/// Each step is fenced by `tokio::time::timeout` against `BANNER_BUDGET`
79/// so a stuck DB never stalls the agent's session start.
80pub async fn render_since_last_session_banner(ctx: &BannerContext) -> Option<String> {
81 // `Result::ok()` collapses an `Err(Elapsed)` from `timeout` into
82 // `None`, which is exactly the "swallow on stall" semantics the
83 // hot path needs. We don't care to distinguish a timeout from a
84 // genuine no-rules-found case — both render as "emit nothing".
85 let result: Result<Option<String>, tokio::time::error::Elapsed> =
86 tokio::time::timeout(BANNER_BUDGET, render_inner(ctx)).await;
87 result.unwrap_or_default()
88}
89
90async fn render_inner(ctx: &BannerContext) -> Option<String> {
91 let project_root = resolve_project_root(&ctx.cwd);
92 let project_hash = difflore_core::db::project_hash_from_root(&project_root);
93
94 let repo_aliases = repo_aliases_for(&project_root);
95 if repo_aliases.is_empty() {
96 // No repo identity — we'd have no way to filter `source_repo`
97 // and would spam every rule from every repo. Bail rather than
98 // produce a misleading banner.
99 return None;
100 }
101
102 let prev_ts = watermark::read_watermark(&project_hash).map(|w| w.ts_ms);
103
104 let Ok(db) = difflore_core::db::init_db().await else {
105 return None;
106 };
107
108 let rows = query::new_rules_since(&db, prev_ts, &repo_aliases, MAX_RULES_IN_BANNER)
109 .await
110 .ok()?;
111
112 // Advance the watermark BEFORE we early-return on "no rows": even an
113 // empty quiet session counts as a session, otherwise the next fire
114 // would still see `prev_ts = None` and show every rule learned to
115 // date. Watermark write failures are swallowed.
116 let now_ms = chrono::Utc::now().timestamp_millis();
117 let _ = watermark::write_watermark(
118 &project_hash,
119 &watermark::Watermark {
120 ts_ms: now_ms,
121 client: ctx.client_name.clone(),
122 },
123 );
124
125 if rows.is_empty() {
126 return None;
127 }
128
129 let prev_label = prev_ts
130 .and_then(timestamp_to_rfc3339)
131 .unwrap_or_else(|| "the start of this repo".to_owned());
132
133 Some(render::format_banner(&rows, &prev_label))
134}
135
136/// Resolve the project root for `cwd`. Tries `git rev-parse
137/// --show-toplevel` (matching `current_project_root`'s logic) but
138/// scoped to the agent's reported cwd rather than the CLI's own. Falls
139/// back to `cwd` itself when git isn't available.
140fn resolve_project_root(cwd: &str) -> std::path::PathBuf {
141 if cwd.is_empty() {
142 return difflore_core::db::current_project_root();
143 }
144 let output = std::process::Command::new("git")
145 .args(["rev-parse", "--show-toplevel"])
146 .current_dir(cwd)
147 .output();
148 if let Ok(out) = output
149 && out.status.success()
150 {
151 let s = String::from_utf8_lossy(&out.stdout).trim().to_owned();
152 if !s.is_empty() {
153 return std::path::PathBuf::from(s);
154 }
155 }
156 std::path::PathBuf::from(cwd)
157}
158
159/// Normalized lower-case `owner/repo` aliases for the project. Matches
160/// the convention used by `commands::status::queries::normalized_repo_aliases`
161/// so the SQL filter joins cleanly with already-stored `source_repo`
162/// values (which were also lowercased on write).
163fn repo_aliases_for(project_root: &std::path::Path) -> Vec<String> {
164 let raw = difflore_core::git::detect_github_repo_full_names(&project_root.to_string_lossy());
165 raw.into_iter()
166 .map(|r| r.trim().to_ascii_lowercase())
167 .filter(|r| !r.is_empty())
168 .collect()
169}
170
171/// Render a unix-ms timestamp as RFC 3339 for the banner header. Returns
172/// `None` if the timestamp is outside chrono's representable range —
173/// in that case the caller falls back to a generic phrase.
174fn timestamp_to_rfc3339(ts_ms: i64) -> Option<String> {
175 chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ts_ms).map(|dt| dt.to_rfc3339())
176}