difflore_cli/hooks/session_banner/query.rs
1//! SQL for "new rules since `prev_ts`, scoped to this repo".
2//!
3//! Reads canonical rules from `~/.difflore/data.db` (the global rule
4//! ledger; per-project context indexes are a separate concern that
5//! the banner doesn't need). We filter `source_repo` against the
6//! current repo's aliases so a user in `acme/billing` doesn't get
7//! spammed with rules learned in `acme/notifier`.
8//!
9//! The query is deliberately narrow:
10//! * `installed_at > prev_ts` (or all rows when `prev_ts` is None)
11//! * `LOWER(source_repo)` in (alias set)
12//! * `status = 'active'` — pending rules aren't yet useful
13//! * ordered by `installed_at` DESC — most recent first
14//! * `LIMIT ?` — banner shape ceiling
15//!
16//! `created_at` in the `rule_events` stream is one floor lower (it
17//! captures every state change); for "new since last session" the
18//! right grain is *rule creation*, which lives on `skills.installed_at`.
19//! Using `rule_events` would surface confidence-bumps as "new rules"
20//! and make the banner noisy.
21
22use sqlx::Row;
23
24/// One row in the banner. Title + provenance fragment ("from PR review
25/// by alice", "from PR merge signature", "manual `difflore rules add`").
26/// The render module is free to truncate either field — both are
27/// passed through verbatim from the DB and may be long.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct NewRule {
30 /// Human-readable rule name as stored in `skills.name`. Often a
31 /// sentence-case headline like "Wrap context cancellation in errgroup".
32 pub title: String,
33 /// One of the `origin` enum values: `manual`, `conversation`,
34 /// `pr_review`, `extracted`. Drives the provenance phrase in the
35 /// rendered banner.
36 pub origin: String,
37 /// `source_repo` value, used only for the optional "← from {repo}"
38 /// suffix when a rule was cross-loaded from another repo. Today
39 /// the query filters to *this* repo's aliases so this is mostly
40 /// the same as the current repo, but the column is preserved for
41 /// future cross-repo widening.
42 pub source_repo: Option<String>,
43}
44
45/// Query `data.db` for up to `limit` rules whose `installed_at` is later
46/// than `prev_ts_ms` (millis since epoch) AND whose `source_repo`
47/// matches one of `repo_aliases` (case-insensitive). When `prev_ts_ms`
48/// is `None`, the time filter is dropped and we return the `limit`
49/// most recent rules for the repo — that's the "first session ever"
50/// case where every rule learned to date is genuinely new to the user.
51///
52/// Returns a Vec ordered newest-first. SQL errors propagate as
53/// `Err(String)` so the caller can fold into the "swallow and emit
54/// nothing" branch.
55pub async fn new_rules_since(
56 db: &difflore_core::SqlitePool,
57 prev_ts_ms: Option<i64>,
58 repo_aliases: &[String],
59 limit: usize,
60) -> Result<Vec<NewRule>, String> {
61 if repo_aliases.is_empty() {
62 // No repo identity → we'd select rules from every repo on
63 // the user's machine, which is the exact noise this filter
64 // exists to prevent.
65 return Ok(Vec::new());
66 }
67
68 // Pre-serialise the alias set into a JSON array sqlx can bind as a
69 // single parameter, then unfold via `json_each` inside the SQL.
70 // Matches the pattern used by `commands::status::queries` so
71 // sqlite's plan cache reuses the same shape.
72 let repos_json = serde_json::to_string(repo_aliases).map_err(|e| format!("aliases: {e}"))?;
73
74 // Convert the watermark millis into a SQLite-compatible UTC
75 // ISO-8601 string. `skills.installed_at` is stored via
76 // `datetime('now')`, which produces `YYYY-MM-DD HH:MM:SS` (no
77 // millisecond precision). Comparing them via `datetime(…)` on
78 // both sides normalises the format. When `prev_ts_ms` is `None`
79 // we pass the unix epoch (1970-01-01), which keeps the SQL
80 // single-shape (no branching) and trivially matches every row.
81 let watermark_iso = prev_ts_ms
82 .and_then(chrono::DateTime::<chrono::Utc>::from_timestamp_millis)
83 .map_or_else(|| "1970-01-01T00:00:00Z".to_owned(), |dt| dt.to_rfc3339());
84
85 let limit_i64 = i64::try_from(limit).unwrap_or(5);
86
87 // The query:
88 // * `status = 'active'` keeps the banner free of pending rules
89 // (those are unverified and could embarrass the user when
90 // surfaced before they hit the review gate).
91 // * `LOWER(source_repo)` matches the alias set generated by
92 // `repo_aliases_for` (which lowercases).
93 // * Sorting by `installed_at` DESC + `id` DESC keeps ties
94 // deterministic (ULID id ordering tracks insertion time, so
95 // the tiebreak surfaces the truly-latest insert when two rules
96 // share a `installed_at` second).
97 let rows = sqlx::query(
98 r"SELECT name, origin, source_repo
99 FROM skills
100 WHERE status = 'active'
101 AND source_repo IS NOT NULL
102 AND TRIM(source_repo) <> ''
103 AND LOWER(source_repo) IN (SELECT value FROM json_each(?1))
104 AND datetime(installed_at) > datetime(?2)
105 ORDER BY datetime(installed_at) DESC, id DESC
106 LIMIT ?3",
107 )
108 .bind(repos_json)
109 .bind(watermark_iso)
110 .bind(limit_i64)
111 .fetch_all(db)
112 .await
113 .map_err(|e| format!("query skills: {e}"))?;
114
115 let mut out = Vec::with_capacity(rows.len());
116 for row in rows {
117 let title: String = row.try_get("name").unwrap_or_default();
118 let origin: String = row
119 .try_get("origin")
120 .unwrap_or_else(|_| "manual".to_owned());
121 let source_repo: Option<String> = row.try_get("source_repo").ok();
122 if title.trim().is_empty() {
123 // Defensive: a corrupted row with no `name` is unhelpful
124 // in the banner. Skip rather than render an empty bullet.
125 continue;
126 }
127 out.push(NewRule {
128 title,
129 origin,
130 source_repo,
131 });
132 }
133 Ok(out)
134}