1use sqlx::SqlitePool;
16
17pub const KIND_RECALLED: &str = "recalled";
18
19#[derive(Debug, Clone)]
20pub struct RuleRecallInput<'a> {
21 pub rule_id: &'a str,
22 pub session_id: Option<&'a str>,
23 pub repo_full_name: Option<&'a str>,
24 pub file_path: Option<&'a str>,
25 pub query_text: &'a str,
26 pub rank: i64,
27 pub top_k: i64,
28 pub strict_file_match: bool,
29}
30
31pub async fn record_recalled(pool: &SqlitePool, rule_ids: &[String]) -> crate::Result<()> {
33 if rule_ids.is_empty() {
34 return Ok(());
35 }
36 let mut tx = pool.begin().await?;
37 for id in rule_ids {
38 sqlx::query!(
39 "INSERT INTO rule_outcomes (rule_id, kind) VALUES (?1, ?2)",
40 id,
41 KIND_RECALLED
42 )
43 .execute(&mut *tx)
44 .await?;
45 }
46 tx.commit().await?;
47 Ok(())
48}
49
50pub async fn record_recalled_with_context(
56 pool: &SqlitePool,
57 recalls: &[RuleRecallInput<'_>],
58) -> crate::Result<()> {
59 if recalls.is_empty() {
60 return Ok(());
61 }
62 let mut tx = pool.begin().await?;
63 for recall in recalls {
64 let query_hash = crate::mcp_rule_serves::query_hash(recall.query_text);
65 let rank = recall.rank.max(1);
66 let top_k = recall.top_k.max(1);
67 let strict = i64::from(recall.strict_file_match);
68 sqlx::query!(
69 "INSERT INTO rule_outcomes
70 (rule_id, kind, session_id, repo_full_name, file_path, query_hash,
71 rank, top_k, strict_file_match)
72 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
73 recall.rule_id,
74 KIND_RECALLED,
75 recall.session_id,
76 recall.repo_full_name,
77 recall.file_path,
78 query_hash,
79 rank,
80 top_k,
81 strict,
82 )
83 .execute(&mut *tx)
84 .await?;
85 }
86 tx.commit().await?;
87 Ok(())
88}
89
90#[derive(Debug, Clone, sqlx::FromRow)]
91pub struct RecallCount {
92 pub rule_id: String,
93 pub count: i64,
94}
95
96#[derive(Debug, Clone, sqlx::FromRow)]
97pub struct RecallSummary {
98 pub recall_events: i64,
99 pub recalled_rules: i64,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
103pub struct AcceptedFixEvidence {
104 pub file_path: Option<String>,
105 pub created_at: String,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
109pub struct TopRecallEvidence {
110 pub rule_id: String,
111 pub repo_full_name: Option<String>,
112 pub file_path: Option<String>,
113 pub rank: i64,
114 pub top_k: i64,
115 pub strict_file_match: bool,
116 pub recalled_at: String,
117}
118
119pub async fn summary(pool: &SqlitePool, days: i64) -> crate::Result<RecallSummary> {
121 let days = days.max(1);
122 let window = format!("-{days} days");
123 let row = sqlx::query_as!(
124 RecallSummary,
125 r#"SELECT
126 COUNT(*) AS "recall_events!: i64",
127 COUNT(DISTINCT rule_id) AS "recalled_rules!: i64"
128 FROM rule_outcomes
129 WHERE kind = 'recalled'
130 AND datetime(created_at) >= datetime('now', ?1)"#,
131 window,
132 )
133 .fetch_one(pool)
134 .await?;
135 Ok(row)
136}
137
138pub async fn top_recalled(
140 pool: &SqlitePool,
141 days: i64,
142 limit: i64,
143) -> crate::Result<Vec<RecallCount>> {
144 let days = days.max(1);
145 let limit = limit.max(1);
146 let window = format!("-{days} days");
147 let rows = sqlx::query_as!(
151 RecallCount,
152 r#"SELECT o.rule_id AS "rule_id!: String", COUNT(*) AS "count!: i64"
153 FROM rule_outcomes o
154 INNER JOIN skills s ON s.id = o.rule_id
155 WHERE o.kind = 'recalled'
156 AND datetime(o.created_at) >= datetime('now', ?1)
157 GROUP BY o.rule_id
158 ORDER BY COUNT(*) DESC, o.rule_id ASC
159 LIMIT ?2"#,
160 window,
161 limit
162 )
163 .fetch_all(pool)
164 .await?;
165 Ok(rows)
166}
167
168pub async fn recall_count_for(pool: &SqlitePool, rule_id: &str, days: i64) -> crate::Result<i64> {
170 let days = days.max(1);
171 let window = format!("-{days} days");
172 let n: i64 = sqlx::query_scalar!(
173 r#"SELECT COUNT(*) AS "n!: i64" FROM rule_outcomes
174 WHERE kind = 'recalled' AND rule_id = ?1
175 AND datetime(created_at) >= datetime('now', ?2)"#,
176 rule_id,
177 window
178 )
179 .fetch_one(pool)
180 .await?;
181 Ok(n)
182}
183
184pub async fn latest_top3_recall_for(
185 pool: &SqlitePool,
186 rule_id: &str,
187 days: i64,
188) -> crate::Result<Option<TopRecallEvidence>> {
189 let days = days.max(1);
190 let window = format!("-{days} days");
191 let row = sqlx::query_as!(
192 TopRecallEvidence,
193 r#"SELECT rule_id AS "rule_id!: String",
194 repo_full_name,
195 file_path,
196 COALESCE(rank, 999) AS "rank!: i64",
197 COALESCE(top_k, 0) AS "top_k!: i64",
198 strict_file_match != 0 AS "strict_file_match!: bool",
199 created_at AS "recalled_at!: String"
200 FROM rule_outcomes
201 WHERE kind = 'recalled'
202 AND rule_id = ?1
203 AND rank BETWEEN 1 AND 3
204 AND datetime(created_at) >= datetime('now', ?2)
205 ORDER BY datetime(created_at) DESC, id DESC
206 LIMIT 1"#,
207 rule_id,
208 window,
209 )
210 .fetch_optional(pool)
211 .await?;
212 Ok(row)
213}
214
215pub async fn fix_accepted_count_for(
219 pool: &SqlitePool,
220 rule_id: &str,
221 days: i64,
222) -> crate::Result<i64> {
223 let days = days.max(1);
224 let window = format!("-{days} days");
225 let n: i64 = sqlx::query_scalar!(
226 r#"SELECT COUNT(*) AS "n!: i64" FROM fix_outcomes
227 WHERE rule_id = ?1 AND accepted = 1 AND applied_ok = 1
228 AND datetime(created_at) >= datetime('now', ?2)"#,
229 rule_id,
230 window
231 )
232 .fetch_one(pool)
233 .await?;
234 Ok(n)
235}
236
237pub async fn latest_accepted_fix_for(
238 pool: &SqlitePool,
239 rule_id: &str,
240 days: i64,
241) -> crate::Result<Option<AcceptedFixEvidence>> {
242 let days = days.max(1);
243 let window = format!("-{days} days");
244 let row = sqlx::query_as!(
245 AcceptedFixEvidence,
246 r#"SELECT file_path, created_at AS "created_at!: String"
247 FROM fix_outcomes
248 WHERE rule_id = ?1
249 AND accepted = 1
250 AND applied_ok = 1
251 AND datetime(created_at) >= datetime('now', ?2)
252 ORDER BY datetime(created_at) DESC, id DESC
253 LIMIT 1"#,
254 rule_id,
255 window,
256 )
257 .fetch_optional(pool)
258 .await?;
259 Ok(row)
260}
261
262#[cfg(test)]
263#[allow(clippy::str_to_string)] mod tests {
265 use super::*;
266 use sqlx::sqlite::SqlitePoolOptions;
267
268 async fn setup() -> SqlitePool {
269 let pool = SqlitePoolOptions::new()
270 .max_connections(1)
271 .connect("sqlite::memory:")
272 .await
273 .expect("open pool");
274 sqlx::migrate!("./migrations")
275 .run(&pool)
276 .await
277 .expect("apply migrations");
278 pool
279 }
280
281 async fn insert_skill(pool: &SqlitePool, id: &str, name: &str) {
282 sqlx::query!(
283 "INSERT INTO skills (id, name, source, directory, version)
284 VALUES (?1, ?2, 'manual', '/tmp', '1.0.0')",
285 id,
286 name,
287 )
288 .execute(pool)
289 .await
290 .expect("insert skill");
291 }
292
293 #[tokio::test]
297 async fn top_recalled_excludes_deleted_rules() {
298 let pool = setup().await;
299 insert_skill(&pool, "r1", "Real rule").await;
300 insert_skill(&pool, "r2", "Soon-deleted rule").await;
301
302 record_recalled(&pool, &["r1".to_owned()])
303 .await
304 .expect("record r1");
305 record_recalled(&pool, &["r2".to_owned(), "r2".to_owned()])
306 .await
307 .expect("record r2");
308
309 sqlx::query!("DELETE FROM skills WHERE id = 'r2'")
311 .execute(&pool)
312 .await
313 .expect("delete r2");
314
315 let rows = top_recalled(&pool, 7, 10).await.expect("top_recalled");
316 let ids: Vec<&str> = rows.iter().map(|r| r.rule_id.as_str()).collect();
317 assert!(ids.contains(&"r1"), "real rule should appear: {ids:?}");
318 assert!(
319 !ids.contains(&"r2"),
320 "deleted rule must not appear: {ids:?}"
321 );
322 }
323
324 #[tokio::test]
325 async fn summary_counts_local_recall_events_and_distinct_rules() {
326 let pool = setup().await;
327 record_recalled(&pool, &["r1".to_owned(), "r2".to_owned()])
328 .await
329 .expect("record first recall");
330 record_recalled(&pool, &["r2".to_owned()])
331 .await
332 .expect("record second recall");
333
334 let row = summary(&pool, 30).await.expect("summary");
335 assert_eq!(row.recall_events, 3);
336 assert_eq!(row.recalled_rules, 2);
337 }
338
339 #[tokio::test]
340 async fn latest_top3_recall_for_requires_ranked_recall_context() {
341 let pool = setup().await;
342 record_recalled_with_context(
343 &pool,
344 &[
345 RuleRecallInput {
346 rule_id: "r1",
347 session_id: Some("session-1"),
348 repo_full_name: Some("acme/widgets"),
349 file_path: Some("src/auth.rs"),
350 query_text: "src/auth.rs validate auth token",
351 rank: 4,
352 top_k: 5,
353 strict_file_match: true,
354 },
355 RuleRecallInput {
356 rule_id: "r1",
357 session_id: Some("session-1"),
358 repo_full_name: Some("acme/widgets"),
359 file_path: Some("src/auth.rs"),
360 query_text: "src/auth.rs validate auth token",
361 rank: 2,
362 top_k: 5,
363 strict_file_match: true,
364 },
365 ],
366 )
367 .await
368 .expect("record ranked recall");
369
370 let recall = latest_top3_recall_for(&pool, "r1", 30)
371 .await
372 .expect("latest recall")
373 .expect("top3 recall");
374
375 assert_eq!(recall.rank, 2);
376 assert_eq!(recall.top_k, 5);
377 assert_eq!(recall.repo_full_name.as_deref(), Some("acme/widgets"));
378 assert_eq!(recall.file_path.as_deref(), Some("src/auth.rs"));
379 assert!(recall.strict_file_match);
380 }
381
382 #[tokio::test]
383 async fn latest_accepted_fix_for_returns_newest_applied_fix() {
384 let pool = setup().await;
385 insert_skill(&pool, "r1", "Real rule").await;
386 sqlx::query!(
387 "INSERT INTO fix_outcomes
388 (id, rule_id, rule_name, file_path, accepted, applied_ok, created_at)
389 VALUES
390 ('f-old', 'r1', 'Real rule', 'src/old.rs', 1, 1, '2026-05-01 00:00:00'),
391 ('f-new', 'r1', 'Real rule', 'src/new.rs', 1, 1, datetime('now')),
392 ('f-rejected', 'r1', 'Real rule', 'src/rejected.rs', 0, 0, datetime('now'))",
393 )
394 .execute(&pool)
395 .await
396 .expect("insert fix outcomes");
397
398 let latest = latest_accepted_fix_for(&pool, "r1", 30)
399 .await
400 .expect("latest accepted fix")
401 .expect("some accepted fix");
402 assert_eq!(latest.file_path.as_deref(), Some("src/new.rs"));
403 }
404}