1use std::sync::Arc;
7
8use zeph_db::DbPool;
9
10#[derive(Debug, Clone, sqlx::FromRow)]
12pub struct CompressionRule {
13 pub id: String,
15 pub tool_glob: Option<String>,
17 pub pattern: String,
19 pub replacement_template: String,
21 pub hit_count: i64,
23 pub source: String,
25 pub created_at: String,
27}
28
29#[derive(Clone)]
35pub struct CompressionRuleStore {
36 pool: Arc<DbPool>,
37}
38
39impl CompressionRuleStore {
40 #[must_use]
42 pub fn new(pool: Arc<DbPool>) -> Self {
43 Self { pool }
44 }
45
46 pub async fn list_active(&self) -> Result<Vec<CompressionRule>, zeph_db::SqlxError> {
52 sqlx::query_as(zeph_db::sql!(
53 "SELECT id, tool_glob, pattern, replacement_template, hit_count, source, created_at \
54 FROM compression_rules ORDER BY hit_count ASC"
55 ))
56 .fetch_all(self.pool.as_ref())
57 .await
58 }
59
60 pub async fn upsert(&self, rule: &CompressionRule) -> Result<(), zeph_db::SqlxError> {
66 sqlx::query(zeph_db::sql!(
67 "INSERT INTO compression_rules \
68 (id, tool_glob, pattern, replacement_template, hit_count, source, created_at) \
69 VALUES (?, ?, ?, ?, ?, ?, ?) \
70 ON CONFLICT(tool_glob, pattern) DO UPDATE SET \
71 replacement_template = excluded.replacement_template, \
72 source = excluded.source"
73 ))
74 .bind(&rule.id)
75 .bind(&rule.tool_glob)
76 .bind(&rule.pattern)
77 .bind(&rule.replacement_template)
78 .bind(rule.hit_count)
79 .bind(&rule.source)
80 .bind(&rule.created_at)
81 .execute(self.pool.as_ref())
82 .await?;
83 Ok(())
84 }
85
86 pub async fn increment_hits(&self, batch: &[(String, u64)]) -> Result<(), zeph_db::SqlxError> {
96 for (id, delta) in batch {
97 sqlx::query(zeph_db::sql!(
98 "UPDATE compression_rules SET hit_count = hit_count + ? WHERE id = ?"
99 ))
100 .bind((*delta).cast_signed())
101 .bind(id.as_str())
102 .execute(self.pool.as_ref())
103 .await?;
104 }
105 Ok(())
106 }
107
108 pub async fn delete(&self, id: &str) -> Result<(), zeph_db::SqlxError> {
114 sqlx::query(zeph_db::sql!("DELETE FROM compression_rules WHERE id = ?"))
115 .bind(id)
116 .execute(self.pool.as_ref())
117 .await?;
118 Ok(())
119 }
120
121 pub async fn prune_lowest_hits(&self, max_rules: u32) -> Result<u64, zeph_db::SqlxError> {
129 let count: i64 =
130 sqlx::query_scalar(zeph_db::sql!("SELECT COUNT(*) FROM compression_rules"))
131 .fetch_one(self.pool.as_ref())
132 .await?;
133
134 if count <= i64::from(max_rules) {
135 return Ok(0);
136 }
137
138 let to_delete = count - i64::from(max_rules);
139 let result = sqlx::query(zeph_db::sql!(
140 "DELETE FROM compression_rules WHERE id IN \
141 (SELECT id FROM compression_rules ORDER BY hit_count ASC LIMIT ?)"
142 ))
143 .bind(to_delete)
144 .execute(self.pool.as_ref())
145 .await?;
146
147 Ok(result.rows_affected())
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use std::sync::Arc;
154
155 use super::{CompressionRule, CompressionRuleStore};
156 use zeph_db::DbPool;
157
158 async fn make_store() -> (CompressionRuleStore, Arc<DbPool>) {
159 let pool = Arc::new(
160 zeph_db::DbConfig {
161 url: ":memory:".to_owned(),
162 ..Default::default()
163 }
164 .connect()
165 .await
166 .unwrap(),
167 );
168 let store = CompressionRuleStore::new(Arc::clone(&pool));
169 (store, pool)
170 }
171
172 fn rule(
173 id: &str,
174 tool_glob: Option<&str>,
175 pattern: &str,
176 replacement: &str,
177 hits: i64,
178 source: &str,
179 ) -> CompressionRule {
180 CompressionRule {
181 id: id.to_owned(),
182 tool_glob: tool_glob.map(ToOwned::to_owned),
183 pattern: pattern.to_owned(),
184 replacement_template: replacement.to_owned(),
185 hit_count: hits,
186 source: source.to_owned(),
187 created_at: "2026-01-01T00:00:00Z".to_owned(),
188 }
189 }
190
191 #[tokio::test]
194 async fn list_active_empty() {
195 let (store, _pool) = make_store().await;
196 let rules = store.list_active().await.unwrap();
197 assert!(rules.is_empty());
198 }
199
200 #[tokio::test]
201 async fn list_active_returns_ordered_by_hits_asc() {
202 let (store, _pool) = make_store().await;
205 store
206 .upsert(&rule("a", None, "pa", "ra", 10, "operator"))
207 .await
208 .unwrap();
209 store
210 .upsert(&rule("b", None, "pb", "rb", 0, "operator"))
211 .await
212 .unwrap();
213 store
214 .upsert(&rule("c", None, "pc", "rc", 5, "operator"))
215 .await
216 .unwrap();
217
218 let rules = store.list_active().await.unwrap();
219 assert_eq!(rules.len(), 3);
220 assert_eq!(rules[0].hit_count, 0);
221 assert_eq!(rules[1].hit_count, 5);
222 assert_eq!(rules[2].hit_count, 10);
223 }
224
225 #[tokio::test]
228 async fn upsert_inserts_new_rule() {
229 let (store, _pool) = make_store().await;
230 store
231 .upsert(&rule("r1", Some("shell"), "pat", "tmpl", 0, "operator"))
232 .await
233 .unwrap();
234
235 let rules = store.list_active().await.unwrap();
236 assert_eq!(rules.len(), 1);
237 let r = &rules[0];
238 assert_eq!(r.id, "r1");
239 assert_eq!(r.tool_glob.as_deref(), Some("shell"));
240 assert_eq!(r.pattern, "pat");
241 assert_eq!(r.replacement_template, "tmpl");
242 assert_eq!(r.source, "operator");
243 }
244
245 #[tokio::test]
246 async fn upsert_conflict_updates_template_and_source() {
247 let (store, _pool) = make_store().await;
249 store
250 .upsert(&rule("r1", Some("shell"), "pat", "old-tmpl", 5, "operator"))
251 .await
252 .unwrap();
253 store
254 .upsert(&rule(
255 "r2",
256 Some("shell"),
257 "pat",
258 "new-tmpl",
259 0,
260 "llm-evolved",
261 ))
262 .await
263 .unwrap();
264
265 let rules = store.list_active().await.unwrap();
266 assert_eq!(rules.len(), 1);
267 assert_eq!(rules[0].id, "r1");
269 assert_eq!(rules[0].replacement_template, "new-tmpl");
270 assert_eq!(rules[0].source, "llm-evolved");
271 assert_eq!(rules[0].hit_count, 5);
273 }
274
275 #[tokio::test]
276 async fn upsert_null_tool_glob_distinct() {
277 let (store, _pool) = make_store().await;
281 store
282 .upsert(&rule("r1", None, "same-pat", "ra", 0, "operator"))
283 .await
284 .unwrap();
285 store
286 .upsert(&rule("r2", None, "same-pat", "rb", 0, "operator"))
287 .await
288 .unwrap();
289
290 let rules = store.list_active().await.unwrap();
291 assert_eq!(rules.len(), 2);
292 }
293
294 #[tokio::test]
295 async fn upsert_preserves_hit_count_on_conflict() {
296 let (store, _pool) = make_store().await;
300 store
301 .upsert(&rule("r1", Some("shell"), "pat", "tmpl", 5, "operator"))
302 .await
303 .unwrap();
304 store
306 .upsert(&rule("r2", Some("shell"), "pat", "tmpl2", 0, "operator"))
307 .await
308 .unwrap();
309
310 let rules = store.list_active().await.unwrap();
311 assert_eq!(rules.len(), 1);
312 assert_eq!(
313 rules[0].hit_count, 5,
314 "hit_count must not be reset by ON CONFLICT"
315 );
316 }
317
318 #[tokio::test]
321 async fn increment_hits_single() {
322 let (store, _pool) = make_store().await;
323 store
324 .upsert(&rule("r1", None, "pat", "tmpl", 0, "operator"))
325 .await
326 .unwrap();
327
328 store.increment_hits(&[("r1".to_owned(), 3)]).await.unwrap();
329
330 let rules = store.list_active().await.unwrap();
331 assert_eq!(rules[0].hit_count, 3);
332 }
333
334 #[tokio::test]
335 async fn increment_hits_batch() {
336 let (store, _pool) = make_store().await;
337 store
338 .upsert(&rule("r1", None, "p1", "t1", 0, "operator"))
339 .await
340 .unwrap();
341 store
342 .upsert(&rule("r2", None, "p2", "t2", 10, "operator"))
343 .await
344 .unwrap();
345 store
346 .upsert(&rule("r3", None, "p3", "t3", 0, "operator"))
347 .await
348 .unwrap();
349
350 store
351 .increment_hits(&[
352 ("r1".to_owned(), 2),
353 ("r2".to_owned(), 5),
354 ("r3".to_owned(), 1),
355 ])
356 .await
357 .unwrap();
358
359 let rules = store.list_active().await.unwrap();
360 let by_id = |id: &str| rules.iter().find(|r| r.id == id).unwrap().hit_count;
361 assert_eq!(by_id("r1"), 2);
362 assert_eq!(by_id("r2"), 15);
363 assert_eq!(by_id("r3"), 1);
364 }
365
366 #[tokio::test]
367 async fn increment_hits_nonexistent_id() {
368 let (store, _pool) = make_store().await;
369 store
371 .increment_hits(&[("ghost".to_owned(), 1)])
372 .await
373 .unwrap();
374 }
375
376 #[tokio::test]
377 async fn increment_hits_empty_batch() {
378 let (store, _pool) = make_store().await;
379 store
380 .upsert(&rule("r1", None, "pat", "tmpl", 7, "operator"))
381 .await
382 .unwrap();
383
384 store.increment_hits(&[]).await.unwrap();
385
386 let rules = store.list_active().await.unwrap();
387 assert_eq!(
388 rules[0].hit_count, 7,
389 "empty batch must not modify existing rules"
390 );
391 }
392
393 #[tokio::test]
396 async fn delete_removes_rule() {
397 let (store, _pool) = make_store().await;
398 store
399 .upsert(&rule("r1", None, "pat", "tmpl", 0, "operator"))
400 .await
401 .unwrap();
402
403 store.delete("r1").await.unwrap();
404
405 let rules = store.list_active().await.unwrap();
406 assert!(rules.is_empty());
407 }
408
409 #[tokio::test]
410 async fn delete_nonexistent_is_noop() {
411 let (store, _pool) = make_store().await;
412 store.delete("ghost").await.unwrap();
414 }
415
416 #[tokio::test]
419 async fn prune_fast_path_no_deletion() {
420 let (store, _pool) = make_store().await;
421 store
422 .upsert(&rule("r1", None, "p1", "t1", 1, "operator"))
423 .await
424 .unwrap();
425 store
426 .upsert(&rule("r2", None, "p2", "t2", 2, "operator"))
427 .await
428 .unwrap();
429
430 let deleted = store.prune_lowest_hits(5).await.unwrap();
431 assert_eq!(deleted, 0);
432 assert_eq!(store.list_active().await.unwrap().len(), 2);
433 }
434
435 #[tokio::test]
436 async fn prune_deletes_lowest_hit_rules() {
437 let (store, _pool) = make_store().await;
438 for (i, hits) in [1i64, 2, 3, 4, 5].iter().enumerate() {
439 store
440 .upsert(&rule(
441 &format!("r{i}"),
442 None,
443 &format!("p{i}"),
444 "t",
445 *hits,
446 "operator",
447 ))
448 .await
449 .unwrap();
450 }
451
452 let deleted = store.prune_lowest_hits(3).await.unwrap();
453 assert_eq!(deleted, 2);
454
455 let remaining = store.list_active().await.unwrap();
456 assert_eq!(remaining.len(), 3);
457 assert!(remaining.iter().all(|r| r.hit_count >= 3));
458 }
459
460 #[tokio::test]
461 async fn prune_exact_boundary() {
462 let (store, _pool) = make_store().await;
463 store
464 .upsert(&rule("r1", None, "p1", "t1", 1, "operator"))
465 .await
466 .unwrap();
467 store
468 .upsert(&rule("r2", None, "p2", "t2", 2, "operator"))
469 .await
470 .unwrap();
471 store
472 .upsert(&rule("r3", None, "p3", "t3", 3, "operator"))
473 .await
474 .unwrap();
475
476 let deleted = store.prune_lowest_hits(3).await.unwrap();
478 assert_eq!(deleted, 0);
479 assert_eq!(store.list_active().await.unwrap().len(), 3);
480 }
481
482 #[tokio::test]
483 async fn prune_max_rules_zero_deletes_all() {
484 let (store, _pool) = make_store().await;
485 store
486 .upsert(&rule("r1", None, "p1", "t1", 1, "operator"))
487 .await
488 .unwrap();
489 store
490 .upsert(&rule("r2", None, "p2", "t2", 2, "operator"))
491 .await
492 .unwrap();
493 store
494 .upsert(&rule("r3", None, "p3", "t3", 3, "operator"))
495 .await
496 .unwrap();
497
498 let deleted = store.prune_lowest_hits(0).await.unwrap();
499 assert_eq!(deleted, 3);
500 assert!(store.list_active().await.unwrap().is_empty());
501 }
502}