Skip to main content

zeph_tools/compression/
store.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! SQLite/Postgres-backed storage for TACO compression rules.
5
6use std::sync::Arc;
7
8use zeph_db::DbPool;
9
10/// A single compression rule stored in the database.
11#[derive(Debug, Clone, sqlx::FromRow)]
12pub struct CompressionRule {
13    /// UUID v4 string identifier.
14    pub id: String,
15    /// Optional glob pattern matching tool names (e.g., `"shell"`, `"web_*"`).
16    pub tool_glob: Option<String>,
17    /// Regex pattern applied to tool output.
18    pub pattern: String,
19    /// Replacement template (may reference capture groups, e.g. `"$1"`).
20    pub replacement_template: String,
21    /// Number of times this rule has matched. Updated by [`CompressionRuleStore::increment_hits`].
22    pub hit_count: i64,
23    /// Origin of this rule: `"operator"` (config-inserted) or `"llm-evolved"` (auto-generated).
24    pub source: String,
25    /// RFC 3339 creation timestamp.
26    pub created_at: String,
27}
28
29/// Persistence layer for TACO compression rules.
30///
31/// All rules are loaded at startup via [`CompressionRuleStore::list_active`] and cached in
32/// [`super::RuleBasedCompressor`]. Hit counts are flushed in batches via
33/// [`CompressionRuleStore::increment_hits`] during the `maybe_autodream` maintenance pass.
34#[derive(Clone)]
35pub struct CompressionRuleStore {
36    pool: Arc<DbPool>,
37}
38
39impl CompressionRuleStore {
40    /// Construct a store backed by the given pool.
41    #[must_use]
42    pub fn new(pool: Arc<DbPool>) -> Self {
43        Self { pool }
44    }
45
46    /// Return all rules, ordered by ascending hit count (least-used first for pruning).
47    ///
48    /// # Errors
49    ///
50    /// Returns a database error on failure.
51    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    /// Insert or update a rule (keyed by `(tool_glob, pattern)`).
61    ///
62    /// # Errors
63    ///
64    /// Returns a database error on failure.
65    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    /// Batch-increment hit counts for a set of rule IDs.
87    ///
88    /// Called during the `maybe_autodream` maintenance pass. Uses individual
89    /// UPDATE statements rather than a batch because the count of rules is small
90    /// and cross-backend portability is preferred.
91    ///
92    /// # Errors
93    ///
94    /// Returns a database error on failure.
95    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    /// Delete a rule by ID.
109    ///
110    /// # Errors
111    ///
112    /// Returns a database error on failure.
113    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    /// Prune the lowest-hit rules to keep the table below `max_rules`.
122    ///
123    /// Returns the number of rules deleted.
124    ///
125    /// # Errors
126    ///
127    /// Returns a database error on failure.
128    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    // --- list_active ---
192
193    #[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        // Distinct hit counts are intentional: ORDER BY hit_count ASC has no tiebreaker,
203        // so equal counts would produce non-deterministic ordering.
204        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    // --- upsert ---
226
227    #[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        // Exercises the common-case conflict path where tool_glob = Some("shell").
248        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        // id must be preserved from the original insert, not overwritten by ON CONFLICT
268        assert_eq!(rules[0].id, "r1");
269        assert_eq!(rules[0].replacement_template, "new-tmpl");
270        assert_eq!(rules[0].source, "llm-evolved");
271        // hit_count must not be overwritten by ON CONFLICT
272        assert_eq!(rules[0].hit_count, 5);
273    }
274
275    #[tokio::test]
276    async fn upsert_null_tool_glob_distinct() {
277        // SQLite treats each NULL as distinct in UNIQUE constraints: (NULL, "pat") and
278        // (NULL, "pat") are NOT considered equal, so both rows can coexist even though
279        // they share the same pattern. This is the key SQLite NULL-in-UNIQUE behavior.
280        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        // Use non-NULL tool_glob so the UNIQUE(tool_glob, pattern) constraint fires.
297        // NULL tool_glob is treated as distinct by SQLite (each NULL ≠ NULL in UNIQUE),
298        // so (NULL, "pat") never produces a conflict — only non-NULL values do.
299        let (store, _pool) = make_store().await;
300        store
301            .upsert(&rule("r1", Some("shell"), "pat", "tmpl", 5, "operator"))
302            .await
303            .unwrap();
304        // Same key (Some("shell"), "pat"), but hit_count=0 in the new row.
305        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    // --- increment_hits ---
319
320    #[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        // UPDATE WHERE id = 'ghost' matches 0 rows — must succeed silently.
370        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    // --- delete ---
394
395    #[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        // Must succeed even when no row with this ID exists.
413        store.delete("ghost").await.unwrap();
414    }
415
416    // --- prune_lowest_hits ---
417
418    #[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        // count == max_rules → fast path, 0 deleted
477        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}