Skip to main content

zeph_memory/sqlite/
skills.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use super::SqliteStore;
5use crate::error::MemoryError;
6
7#[derive(Debug)]
8pub struct SkillUsageRow {
9    pub skill_name: String,
10    pub invocation_count: i64,
11    pub last_used_at: String,
12}
13
14#[derive(Debug)]
15pub struct SkillMetricsRow {
16    pub skill_name: String,
17    pub version_id: Option<i64>,
18    pub total: i64,
19    pub successes: i64,
20    pub failures: i64,
21}
22
23#[derive(Debug)]
24pub struct SkillVersionRow {
25    pub id: i64,
26    pub skill_name: String,
27    pub version: i64,
28    pub body: String,
29    pub description: String,
30    pub source: String,
31    pub is_active: bool,
32    pub success_count: i64,
33    pub failure_count: i64,
34    pub created_at: String,
35}
36
37type SkillVersionTuple = (
38    i64,
39    String,
40    i64,
41    String,
42    String,
43    String,
44    i64,
45    i64,
46    i64,
47    String,
48);
49
50fn skill_version_from_tuple(t: SkillVersionTuple) -> SkillVersionRow {
51    SkillVersionRow {
52        id: t.0,
53        skill_name: t.1,
54        version: t.2,
55        body: t.3,
56        description: t.4,
57        source: t.5,
58        is_active: t.6 != 0,
59        success_count: t.7,
60        failure_count: t.8,
61        created_at: t.9,
62    }
63}
64
65impl SqliteStore {
66    /// Record usage of skills (UPSERT: increment count and update timestamp).
67    ///
68    /// # Errors
69    ///
70    /// Returns an error if the database operation fails.
71    pub async fn record_skill_usage(&self, skill_names: &[&str]) -> Result<(), MemoryError> {
72        for name in skill_names {
73            sqlx::query(
74                "INSERT INTO skill_usage (skill_name, invocation_count, last_used_at) \
75                 VALUES (?, 1, datetime('now')) \
76                 ON CONFLICT(skill_name) DO UPDATE SET \
77                 invocation_count = invocation_count + 1, \
78                 last_used_at = datetime('now')",
79            )
80            .bind(name)
81            .execute(&self.pool)
82            .await?;
83        }
84        Ok(())
85    }
86
87    /// Load all skill usage statistics.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if the query fails.
92    pub async fn load_skill_usage(&self) -> Result<Vec<SkillUsageRow>, MemoryError> {
93        let rows: Vec<(String, i64, String)> = sqlx::query_as(
94            "SELECT skill_name, invocation_count, last_used_at \
95             FROM skill_usage ORDER BY invocation_count DESC",
96        )
97        .fetch_all(&self.pool)
98        .await?;
99
100        Ok(rows
101            .into_iter()
102            .map(
103                |(skill_name, invocation_count, last_used_at)| SkillUsageRow {
104                    skill_name,
105                    invocation_count,
106                    last_used_at,
107                },
108            )
109            .collect())
110    }
111
112    /// Record a skill outcome event.
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if the insert fails.
117    pub async fn record_skill_outcome(
118        &self,
119        skill_name: &str,
120        version_id: Option<i64>,
121        conversation_id: Option<crate::types::ConversationId>,
122        outcome: &str,
123        error_context: Option<&str>,
124    ) -> Result<(), MemoryError> {
125        sqlx::query(
126            "INSERT INTO skill_outcomes (skill_name, version_id, conversation_id, outcome, error_context) \
127             VALUES (?, ?, ?, ?, ?)",
128        )
129        .bind(skill_name)
130        .bind(version_id)
131        .bind(conversation_id)
132        .bind(outcome)
133        .bind(error_context)
134        .execute(&self.pool)
135        .await
136        ?;
137        Ok(())
138    }
139
140    /// Record outcomes for multiple skills in a single transaction.
141    ///
142    /// # Errors
143    ///
144    /// Returns an error if any insert fails (whole batch is rolled back).
145    pub async fn record_skill_outcomes_batch(
146        &self,
147        skill_names: &[String],
148        conversation_id: Option<crate::types::ConversationId>,
149        outcome: &str,
150        error_context: Option<&str>,
151    ) -> Result<(), MemoryError> {
152        let mut tx = self.pool.begin().await?;
153        for name in skill_names {
154            sqlx::query(
155                "INSERT INTO skill_outcomes \
156                 (skill_name, version_id, conversation_id, outcome, error_context) \
157                 VALUES (?, ?, ?, ?, ?)",
158            )
159            .bind(name)
160            .bind(None::<i64>)
161            .bind(conversation_id)
162            .bind(outcome)
163            .bind(error_context)
164            .execute(&mut *tx)
165            .await?;
166        }
167        tx.commit().await?;
168        Ok(())
169    }
170
171    /// Load metrics for a skill (latest version group).
172    ///
173    /// # Errors
174    ///
175    /// Returns an error if the query fails.
176    pub async fn skill_metrics(
177        &self,
178        skill_name: &str,
179    ) -> Result<Option<SkillMetricsRow>, MemoryError> {
180        let row: Option<(String, Option<i64>, i64, i64, i64)> = sqlx::query_as(
181            "SELECT skill_name, version_id, \
182             COUNT(*) as total, \
183             SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as successes, \
184             COUNT(*) - SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as failures \
185             FROM skill_outcomes WHERE skill_name = ? \
186             GROUP BY skill_name, version_id \
187             ORDER BY version_id DESC LIMIT 1",
188        )
189        .bind(skill_name)
190        .fetch_optional(&self.pool)
191        .await?;
192
193        Ok(row.map(
194            |(skill_name, version_id, total, successes, failures)| SkillMetricsRow {
195                skill_name,
196                version_id,
197                total,
198                successes,
199                failures,
200            },
201        ))
202    }
203
204    /// Load all skill outcome stats grouped by skill name.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if the query fails.
209    pub async fn load_skill_outcome_stats(&self) -> Result<Vec<SkillMetricsRow>, MemoryError> {
210        let rows: Vec<(String, Option<i64>, i64, i64, i64)> = sqlx::query_as(
211            "SELECT skill_name, version_id, \
212             COUNT(*) as total, \
213             SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as successes, \
214             COUNT(*) - SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as failures \
215             FROM skill_outcomes \
216             GROUP BY skill_name \
217             ORDER BY total DESC",
218        )
219        .fetch_all(&self.pool)
220        .await?;
221
222        Ok(rows
223            .into_iter()
224            .map(
225                |(skill_name, version_id, total, successes, failures)| SkillMetricsRow {
226                    skill_name,
227                    version_id,
228                    total,
229                    successes,
230                    failures,
231                },
232            )
233            .collect())
234    }
235
236    /// Save a new skill version and return its ID.
237    ///
238    /// # Errors
239    ///
240    /// Returns an error if the insert fails.
241    #[allow(clippy::too_many_arguments)]
242    pub async fn save_skill_version(
243        &self,
244        skill_name: &str,
245        version: i64,
246        body: &str,
247        description: &str,
248        source: &str,
249        error_context: Option<&str>,
250        predecessor_id: Option<i64>,
251    ) -> Result<i64, MemoryError> {
252        let row: (i64,) = sqlx::query_as(
253            "INSERT INTO skill_versions \
254             (skill_name, version, body, description, source, error_context, predecessor_id) \
255             VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id",
256        )
257        .bind(skill_name)
258        .bind(version)
259        .bind(body)
260        .bind(description)
261        .bind(source)
262        .bind(error_context)
263        .bind(predecessor_id)
264        .fetch_one(&self.pool)
265        .await?;
266        Ok(row.0)
267    }
268
269    /// Load the active version for a skill.
270    ///
271    /// # Errors
272    ///
273    /// Returns an error if the query fails.
274    pub async fn active_skill_version(
275        &self,
276        skill_name: &str,
277    ) -> Result<Option<SkillVersionRow>, MemoryError> {
278        let row: Option<SkillVersionTuple> = sqlx::query_as(
279            "SELECT id, skill_name, version, body, description, source, \
280                 is_active, success_count, failure_count, created_at \
281                 FROM skill_versions WHERE skill_name = ? AND is_active = 1 LIMIT 1",
282        )
283        .bind(skill_name)
284        .fetch_optional(&self.pool)
285        .await?;
286
287        Ok(row.map(skill_version_from_tuple))
288    }
289
290    /// Activate a specific version (deactivates others for the same skill).
291    ///
292    /// # Errors
293    ///
294    /// Returns an error if the update fails.
295    pub async fn activate_skill_version(
296        &self,
297        skill_name: &str,
298        version_id: i64,
299    ) -> Result<(), MemoryError> {
300        let mut tx = self.pool.begin().await?;
301
302        sqlx::query(
303            "UPDATE skill_versions SET is_active = 0 WHERE skill_name = ? AND is_active = 1",
304        )
305        .bind(skill_name)
306        .execute(&mut *tx)
307        .await?;
308
309        sqlx::query("UPDATE skill_versions SET is_active = 1 WHERE id = ?")
310            .bind(version_id)
311            .execute(&mut *tx)
312            .await?;
313
314        tx.commit().await?;
315        Ok(())
316    }
317
318    /// Get the next version number for a skill.
319    ///
320    /// # Errors
321    ///
322    /// Returns an error if the query fails.
323    pub async fn next_skill_version(&self, skill_name: &str) -> Result<i64, MemoryError> {
324        let row: (i64,) = sqlx::query_as(
325            "SELECT COALESCE(MAX(version), 0) + 1 FROM skill_versions WHERE skill_name = ?",
326        )
327        .bind(skill_name)
328        .fetch_one(&self.pool)
329        .await?;
330        Ok(row.0)
331    }
332
333    /// Get the latest auto-generated version's `created_at` for cooldown check.
334    ///
335    /// # Errors
336    ///
337    /// Returns an error if the query fails.
338    pub async fn last_improvement_time(
339        &self,
340        skill_name: &str,
341    ) -> Result<Option<String>, MemoryError> {
342        let row: Option<(String,)> = sqlx::query_as(
343            "SELECT created_at FROM skill_versions \
344             WHERE skill_name = ? AND source = 'auto' \
345             ORDER BY id DESC LIMIT 1",
346        )
347        .bind(skill_name)
348        .fetch_optional(&self.pool)
349        .await?;
350        Ok(row.map(|r| r.0))
351    }
352
353    /// Ensure a base (v1 manual) version exists for a skill. Idempotent.
354    ///
355    /// # Errors
356    ///
357    /// Returns an error if the DB operation fails.
358    pub async fn ensure_skill_version_exists(
359        &self,
360        skill_name: &str,
361        body: &str,
362        description: &str,
363    ) -> Result<(), MemoryError> {
364        let existing: Option<(i64,)> =
365            sqlx::query_as("SELECT id FROM skill_versions WHERE skill_name = ? LIMIT 1")
366                .bind(skill_name)
367                .fetch_optional(&self.pool)
368                .await?;
369
370        if existing.is_none() {
371            let id = self
372                .save_skill_version(skill_name, 1, body, description, "manual", None, None)
373                .await?;
374            self.activate_skill_version(skill_name, id).await?;
375        }
376        Ok(())
377    }
378
379    /// Load all versions for a skill, ordered by version number.
380    ///
381    /// # Errors
382    ///
383    /// Returns an error if the query fails.
384    pub async fn load_skill_versions(
385        &self,
386        skill_name: &str,
387    ) -> Result<Vec<SkillVersionRow>, MemoryError> {
388        let rows: Vec<SkillVersionTuple> = sqlx::query_as(
389            "SELECT id, skill_name, version, body, description, source, \
390                 is_active, success_count, failure_count, created_at \
391                 FROM skill_versions WHERE skill_name = ? ORDER BY version ASC",
392        )
393        .bind(skill_name)
394        .fetch_all(&self.pool)
395        .await?;
396
397        Ok(rows.into_iter().map(skill_version_from_tuple).collect())
398    }
399
400    /// Count auto-generated versions for a skill.
401    ///
402    /// # Errors
403    ///
404    /// Returns an error if the query fails.
405    pub async fn count_auto_versions(&self, skill_name: &str) -> Result<i64, MemoryError> {
406        let row: (i64,) = sqlx::query_as(
407            "SELECT COUNT(*) FROM skill_versions WHERE skill_name = ? AND source = 'auto'",
408        )
409        .bind(skill_name)
410        .fetch_one(&self.pool)
411        .await?;
412        Ok(row.0)
413    }
414
415    /// Delete oldest non-active auto versions exceeding max limit.
416    /// Returns the number of pruned versions.
417    ///
418    /// # Errors
419    ///
420    /// Returns an error if the delete fails.
421    pub async fn prune_skill_versions(
422        &self,
423        skill_name: &str,
424        max_versions: u32,
425    ) -> Result<u32, MemoryError> {
426        let result = sqlx::query(
427            "DELETE FROM skill_versions WHERE id IN (\
428                SELECT id FROM skill_versions \
429                WHERE skill_name = ? AND source = 'auto' AND is_active = 0 \
430                ORDER BY id ASC \
431                LIMIT max(0, (SELECT COUNT(*) FROM skill_versions \
432                    WHERE skill_name = ? AND source = 'auto') - ?)\
433            )",
434        )
435        .bind(skill_name)
436        .bind(skill_name)
437        .bind(max_versions)
438        .execute(&self.pool)
439        .await?;
440        Ok(u32::try_from(result.rows_affected()).unwrap_or(0))
441    }
442
443    /// Get the predecessor version for rollback.
444    ///
445    /// # Errors
446    ///
447    /// Returns an error if the query fails.
448    pub async fn predecessor_version(
449        &self,
450        version_id: i64,
451    ) -> Result<Option<SkillVersionRow>, MemoryError> {
452        let pred_id: Option<(Option<i64>,)> =
453            sqlx::query_as("SELECT predecessor_id FROM skill_versions WHERE id = ?")
454                .bind(version_id)
455                .fetch_optional(&self.pool)
456                .await?;
457
458        let Some((Some(pid),)) = pred_id else {
459            return Ok(None);
460        };
461
462        let row: Option<SkillVersionTuple> = sqlx::query_as(
463            "SELECT id, skill_name, version, body, description, source, \
464                 is_active, success_count, failure_count, created_at \
465                 FROM skill_versions WHERE id = ?",
466        )
467        .bind(pid)
468        .fetch_optional(&self.pool)
469        .await?;
470
471        Ok(row.map(skill_version_from_tuple))
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    async fn test_store() -> SqliteStore {
480        SqliteStore::new(":memory:").await.unwrap()
481    }
482
483    #[tokio::test]
484    async fn record_skill_usage_increments() {
485        let store = test_store().await;
486
487        store.record_skill_usage(&["git"]).await.unwrap();
488        store.record_skill_usage(&["git"]).await.unwrap();
489
490        let usage = store.load_skill_usage().await.unwrap();
491        assert_eq!(usage.len(), 1);
492        assert_eq!(usage[0].skill_name, "git");
493        assert_eq!(usage[0].invocation_count, 2);
494    }
495
496    #[tokio::test]
497    async fn load_skill_usage_returns_all() {
498        let store = test_store().await;
499
500        store.record_skill_usage(&["git", "docker"]).await.unwrap();
501        store.record_skill_usage(&["git"]).await.unwrap();
502
503        let usage = store.load_skill_usage().await.unwrap();
504        assert_eq!(usage.len(), 2);
505        assert_eq!(usage[0].skill_name, "git");
506        assert_eq!(usage[0].invocation_count, 2);
507        assert_eq!(usage[1].skill_name, "docker");
508        assert_eq!(usage[1].invocation_count, 1);
509    }
510
511    #[tokio::test]
512    async fn migration_005_creates_tables() {
513        let store = test_store().await;
514        let pool = store.pool();
515
516        let versions: (i64,) = sqlx::query_as(
517            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='skill_versions'",
518        )
519        .fetch_one(pool)
520        .await
521        .unwrap();
522        assert_eq!(versions.0, 1);
523
524        let outcomes: (i64,) = sqlx::query_as(
525            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='skill_outcomes'",
526        )
527        .fetch_one(pool)
528        .await
529        .unwrap();
530        assert_eq!(outcomes.0, 1);
531    }
532
533    #[tokio::test]
534    async fn record_skill_outcome_inserts() {
535        let store = test_store().await;
536
537        store
538            .record_skill_outcome(
539                "git",
540                None,
541                Some(crate::types::ConversationId(1)),
542                "success",
543                None,
544            )
545            .await
546            .unwrap();
547        store
548            .record_skill_outcome(
549                "git",
550                None,
551                Some(crate::types::ConversationId(1)),
552                "tool_failure",
553                Some("exit code 1"),
554            )
555            .await
556            .unwrap();
557
558        let metrics = store.skill_metrics("git").await.unwrap().unwrap();
559        assert_eq!(metrics.total, 2);
560        assert_eq!(metrics.successes, 1);
561        assert_eq!(metrics.failures, 1);
562    }
563
564    #[tokio::test]
565    async fn skill_metrics_none_for_unknown() {
566        let store = test_store().await;
567        let m = store.skill_metrics("nonexistent").await.unwrap();
568        assert!(m.is_none());
569    }
570
571    #[tokio::test]
572    async fn load_skill_outcome_stats_grouped() {
573        let store = test_store().await;
574
575        store
576            .record_skill_outcome("git", None, None, "success", None)
577            .await
578            .unwrap();
579        store
580            .record_skill_outcome("git", None, None, "tool_failure", None)
581            .await
582            .unwrap();
583        store
584            .record_skill_outcome("docker", None, None, "success", None)
585            .await
586            .unwrap();
587
588        let stats = store.load_skill_outcome_stats().await.unwrap();
589        assert_eq!(stats.len(), 2);
590        assert_eq!(stats[0].skill_name, "git");
591        assert_eq!(stats[0].total, 2);
592        assert_eq!(stats[1].skill_name, "docker");
593        assert_eq!(stats[1].total, 1);
594    }
595
596    #[tokio::test]
597    async fn save_and_load_skill_version() {
598        let store = test_store().await;
599
600        let id = store
601            .save_skill_version("git", 1, "body v1", "Git helper", "manual", None, None)
602            .await
603            .unwrap();
604        assert!(id > 0);
605
606        store.activate_skill_version("git", id).await.unwrap();
607
608        let active = store.active_skill_version("git").await.unwrap().unwrap();
609        assert_eq!(active.version, 1);
610        assert_eq!(active.body, "body v1");
611        assert!(active.is_active);
612    }
613
614    #[tokio::test]
615    async fn activate_deactivates_previous() {
616        let store = test_store().await;
617
618        let v1 = store
619            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
620            .await
621            .unwrap();
622        store.activate_skill_version("git", v1).await.unwrap();
623
624        let v2 = store
625            .save_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
626            .await
627            .unwrap();
628        store.activate_skill_version("git", v2).await.unwrap();
629
630        let versions = store.load_skill_versions("git").await.unwrap();
631        assert_eq!(versions.len(), 2);
632        assert!(!versions[0].is_active);
633        assert!(versions[1].is_active);
634    }
635
636    #[tokio::test]
637    async fn next_skill_version_increments() {
638        let store = test_store().await;
639
640        let next = store.next_skill_version("git").await.unwrap();
641        assert_eq!(next, 1);
642
643        store
644            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
645            .await
646            .unwrap();
647        let next = store.next_skill_version("git").await.unwrap();
648        assert_eq!(next, 2);
649    }
650
651    #[tokio::test]
652    async fn last_improvement_time_returns_auto_only() {
653        let store = test_store().await;
654
655        store
656            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
657            .await
658            .unwrap();
659
660        let t = store.last_improvement_time("git").await.unwrap();
661        assert!(t.is_none());
662
663        store
664            .save_skill_version("git", 2, "v2", "desc", "auto", None, None)
665            .await
666            .unwrap();
667
668        let t = store.last_improvement_time("git").await.unwrap();
669        assert!(t.is_some());
670    }
671
672    #[tokio::test]
673    async fn ensure_skill_version_exists_idempotent() {
674        let store = test_store().await;
675
676        store
677            .ensure_skill_version_exists("git", "body", "Git helper")
678            .await
679            .unwrap();
680        store
681            .ensure_skill_version_exists("git", "body2", "Git helper 2")
682            .await
683            .unwrap();
684
685        let versions = store.load_skill_versions("git").await.unwrap();
686        assert_eq!(versions.len(), 1);
687        assert_eq!(versions[0].body, "body");
688    }
689
690    #[tokio::test]
691    async fn load_skill_versions_ordered() {
692        let store = test_store().await;
693
694        let v1 = store
695            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
696            .await
697            .unwrap();
698        store
699            .save_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
700            .await
701            .unwrap();
702
703        let versions = store.load_skill_versions("git").await.unwrap();
704        assert_eq!(versions.len(), 2);
705        assert_eq!(versions[0].version, 1);
706        assert_eq!(versions[1].version, 2);
707    }
708
709    #[tokio::test]
710    async fn count_auto_versions_only() {
711        let store = test_store().await;
712
713        store
714            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
715            .await
716            .unwrap();
717        store
718            .save_skill_version("git", 2, "v2", "desc", "auto", None, None)
719            .await
720            .unwrap();
721        store
722            .save_skill_version("git", 3, "v3", "desc", "auto", None, None)
723            .await
724            .unwrap();
725
726        let count = store.count_auto_versions("git").await.unwrap();
727        assert_eq!(count, 2);
728    }
729
730    #[tokio::test]
731    async fn prune_preserves_manual_and_active() {
732        let store = test_store().await;
733
734        let v1 = store
735            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
736            .await
737            .unwrap();
738        store.activate_skill_version("git", v1).await.unwrap();
739
740        for i in 2..=5 {
741            store
742                .save_skill_version("git", i, &format!("v{i}"), "desc", "auto", None, None)
743                .await
744                .unwrap();
745        }
746
747        let pruned = store.prune_skill_versions("git", 2).await.unwrap();
748        assert_eq!(pruned, 2);
749
750        let versions = store.load_skill_versions("git").await.unwrap();
751        assert!(versions.iter().any(|v| v.source == "manual"));
752        let auto_count = versions.iter().filter(|v| v.source == "auto").count();
753        assert_eq!(auto_count, 2);
754    }
755
756    #[tokio::test]
757    async fn predecessor_version_returns_parent() {
758        let store = test_store().await;
759
760        let v1 = store
761            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
762            .await
763            .unwrap();
764        let v2 = store
765            .save_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
766            .await
767            .unwrap();
768
769        let pred = store.predecessor_version(v2).await.unwrap().unwrap();
770        assert_eq!(pred.id, v1);
771        assert_eq!(pred.version, 1);
772    }
773
774    #[tokio::test]
775    async fn predecessor_version_none_for_root() {
776        let store = test_store().await;
777
778        let v1 = store
779            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
780            .await
781            .unwrap();
782
783        let pred = store.predecessor_version(v1).await.unwrap();
784        assert!(pred.is_none());
785    }
786
787    #[tokio::test]
788    async fn active_skill_version_none_for_unknown() {
789        let store = test_store().await;
790        let active = store.active_skill_version("nonexistent").await.unwrap();
791        assert!(active.is_none());
792    }
793
794    #[tokio::test]
795    async fn load_skill_outcome_stats_empty() {
796        let store = test_store().await;
797        let stats = store.load_skill_outcome_stats().await.unwrap();
798        assert!(stats.is_empty());
799    }
800
801    #[tokio::test]
802    async fn load_skill_versions_empty() {
803        let store = test_store().await;
804        let versions = store.load_skill_versions("nonexistent").await.unwrap();
805        assert!(versions.is_empty());
806    }
807
808    #[tokio::test]
809    async fn count_auto_versions_zero_for_unknown() {
810        let store = test_store().await;
811        let count = store.count_auto_versions("nonexistent").await.unwrap();
812        assert_eq!(count, 0);
813    }
814
815    #[tokio::test]
816    async fn prune_nothing_when_below_limit() {
817        let store = test_store().await;
818
819        store
820            .save_skill_version("git", 1, "v1", "desc", "auto", None, None)
821            .await
822            .unwrap();
823
824        let pruned = store.prune_skill_versions("git", 5).await.unwrap();
825        assert_eq!(pruned, 0);
826    }
827
828    #[tokio::test]
829    async fn record_skill_outcome_with_error_context() {
830        let store = test_store().await;
831
832        store
833            .record_skill_outcome(
834                "docker",
835                None,
836                Some(crate::types::ConversationId(1)),
837                "tool_failure",
838                Some("container not found"),
839            )
840            .await
841            .unwrap();
842
843        let metrics = store.skill_metrics("docker").await.unwrap().unwrap();
844        assert_eq!(metrics.total, 1);
845        assert_eq!(metrics.failures, 1);
846    }
847
848    #[tokio::test]
849    async fn save_skill_version_with_error_context() {
850        let store = test_store().await;
851
852        let id = store
853            .save_skill_version(
854                "git",
855                1,
856                "improved body",
857                "Git helper",
858                "auto",
859                Some("exit code 128"),
860                None,
861            )
862            .await
863            .unwrap();
864        assert!(id > 0);
865    }
866}