Skip to main content

zeph_memory/sqlite/
skills.rs

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