Skip to main content

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}