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        outcome_detail: Option<&str>,
125    ) -> Result<(), MemoryError> {
126        sqlx::query(
127            "INSERT INTO skill_outcomes \
128             (skill_name, version_id, conversation_id, outcome, error_context, outcome_detail) \
129             VALUES (?, ?, ?, ?, ?, ?)",
130        )
131        .bind(skill_name)
132        .bind(version_id)
133        .bind(conversation_id)
134        .bind(outcome)
135        .bind(error_context)
136        .bind(outcome_detail)
137        .execute(&self.pool)
138        .await?;
139        Ok(())
140    }
141
142    /// Record outcomes for multiple skills in a single transaction.
143    ///
144    /// # Errors
145    ///
146    /// Returns an error if any insert fails (whole batch is rolled back).
147    pub async fn record_skill_outcomes_batch(
148        &self,
149        skill_names: &[String],
150        conversation_id: Option<crate::types::ConversationId>,
151        outcome: &str,
152        error_context: Option<&str>,
153        outcome_detail: Option<&str>,
154    ) -> Result<(), MemoryError> {
155        let mut tx = self.pool.begin().await?;
156
157        let mut version_map: std::collections::HashMap<String, Option<i64>> =
158            std::collections::HashMap::new();
159        for name in skill_names {
160            let vid: Option<(i64,)> = sqlx::query_as(
161                "SELECT id FROM skill_versions WHERE skill_name = ? AND is_active = 1",
162            )
163            .bind(name)
164            .fetch_optional(&mut *tx)
165            .await?;
166            version_map.insert(name.clone(), vid.map(|r| r.0));
167        }
168
169        for name in skill_names {
170            let version_id = version_map.get(name.as_str()).copied().flatten();
171            sqlx::query(
172                "INSERT INTO skill_outcomes \
173                 (skill_name, version_id, conversation_id, outcome, error_context, outcome_detail) \
174                 VALUES (?, ?, ?, ?, ?, ?)",
175            )
176            .bind(name)
177            .bind(version_id)
178            .bind(conversation_id)
179            .bind(outcome)
180            .bind(error_context)
181            .bind(outcome_detail)
182            .execute(&mut *tx)
183            .await?;
184        }
185        tx.commit().await?;
186        Ok(())
187    }
188
189    /// Load metrics for a skill (latest version group).
190    ///
191    /// # Errors
192    ///
193    /// Returns an error if the query fails.
194    pub async fn skill_metrics(
195        &self,
196        skill_name: &str,
197    ) -> Result<Option<SkillMetricsRow>, MemoryError> {
198        let row: Option<(String, Option<i64>, i64, i64, i64)> = sqlx::query_as(
199            "SELECT skill_name, version_id, \
200             COUNT(*) as total, \
201             SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as successes, \
202             COUNT(*) - SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as failures \
203             FROM skill_outcomes WHERE skill_name = ? \
204             GROUP BY skill_name, version_id \
205             ORDER BY version_id DESC LIMIT 1",
206        )
207        .bind(skill_name)
208        .fetch_optional(&self.pool)
209        .await?;
210
211        Ok(row.map(
212            |(skill_name, version_id, total, successes, failures)| SkillMetricsRow {
213                skill_name,
214                version_id,
215                total,
216                successes,
217                failures,
218            },
219        ))
220    }
221
222    /// Load all skill outcome stats grouped by skill name.
223    ///
224    /// # Errors
225    ///
226    /// Returns an error if the query fails.
227    pub async fn load_skill_outcome_stats(&self) -> Result<Vec<SkillMetricsRow>, MemoryError> {
228        let rows: Vec<(String, Option<i64>, i64, i64, i64)> = sqlx::query_as(
229            "SELECT skill_name, version_id, \
230             COUNT(*) as total, \
231             SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as successes, \
232             COUNT(*) - SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as failures \
233             FROM skill_outcomes \
234             GROUP BY skill_name \
235             ORDER BY total DESC",
236        )
237        .fetch_all(&self.pool)
238        .await?;
239
240        Ok(rows
241            .into_iter()
242            .map(
243                |(skill_name, version_id, total, successes, failures)| SkillMetricsRow {
244                    skill_name,
245                    version_id,
246                    total,
247                    successes,
248                    failures,
249                },
250            )
251            .collect())
252    }
253
254    /// Save a new skill version and return its ID.
255    ///
256    /// # Errors
257    ///
258    /// Returns an error if the insert fails.
259    #[allow(clippy::too_many_arguments)]
260    pub async fn save_skill_version(
261        &self,
262        skill_name: &str,
263        version: i64,
264        body: &str,
265        description: &str,
266        source: &str,
267        error_context: Option<&str>,
268        predecessor_id: Option<i64>,
269    ) -> Result<i64, MemoryError> {
270        let row: (i64,) = sqlx::query_as(
271            "INSERT INTO skill_versions \
272             (skill_name, version, body, description, source, error_context, predecessor_id) \
273             VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id",
274        )
275        .bind(skill_name)
276        .bind(version)
277        .bind(body)
278        .bind(description)
279        .bind(source)
280        .bind(error_context)
281        .bind(predecessor_id)
282        .fetch_one(&self.pool)
283        .await?;
284        Ok(row.0)
285    }
286
287    /// Load the active version for a skill.
288    ///
289    /// # Errors
290    ///
291    /// Returns an error if the query fails.
292    pub async fn active_skill_version(
293        &self,
294        skill_name: &str,
295    ) -> Result<Option<SkillVersionRow>, MemoryError> {
296        let row: Option<SkillVersionTuple> = sqlx::query_as(
297            "SELECT id, skill_name, version, body, description, source, \
298                 is_active, success_count, failure_count, created_at \
299                 FROM skill_versions WHERE skill_name = ? AND is_active = 1 LIMIT 1",
300        )
301        .bind(skill_name)
302        .fetch_optional(&self.pool)
303        .await?;
304
305        Ok(row.map(skill_version_from_tuple))
306    }
307
308    /// Activate a specific version (deactivates others for the same skill).
309    ///
310    /// # Errors
311    ///
312    /// Returns an error if the update fails.
313    pub async fn activate_skill_version(
314        &self,
315        skill_name: &str,
316        version_id: i64,
317    ) -> Result<(), MemoryError> {
318        let mut tx = self.pool.begin().await?;
319
320        sqlx::query(
321            "UPDATE skill_versions SET is_active = 0 WHERE skill_name = ? AND is_active = 1",
322        )
323        .bind(skill_name)
324        .execute(&mut *tx)
325        .await?;
326
327        sqlx::query("UPDATE skill_versions SET is_active = 1 WHERE id = ?")
328            .bind(version_id)
329            .execute(&mut *tx)
330            .await?;
331
332        tx.commit().await?;
333        Ok(())
334    }
335
336    /// Get the next version number for a skill.
337    ///
338    /// # Errors
339    ///
340    /// Returns an error if the query fails.
341    pub async fn next_skill_version(&self, skill_name: &str) -> Result<i64, MemoryError> {
342        let row: (i64,) = sqlx::query_as(
343            "SELECT COALESCE(MAX(version), 0) + 1 FROM skill_versions WHERE skill_name = ?",
344        )
345        .bind(skill_name)
346        .fetch_one(&self.pool)
347        .await?;
348        Ok(row.0)
349    }
350
351    /// Get the latest auto-generated version's `created_at` for cooldown check.
352    ///
353    /// # Errors
354    ///
355    /// Returns an error if the query fails.
356    pub async fn last_improvement_time(
357        &self,
358        skill_name: &str,
359    ) -> Result<Option<String>, MemoryError> {
360        let row: Option<(String,)> = sqlx::query_as(
361            "SELECT created_at FROM skill_versions \
362             WHERE skill_name = ? AND source = 'auto' \
363             ORDER BY id DESC LIMIT 1",
364        )
365        .bind(skill_name)
366        .fetch_optional(&self.pool)
367        .await?;
368        Ok(row.map(|r| r.0))
369    }
370
371    /// Ensure a base (v1 manual) version exists for a skill. Idempotent.
372    ///
373    /// # Errors
374    ///
375    /// Returns an error if the DB operation fails.
376    pub async fn ensure_skill_version_exists(
377        &self,
378        skill_name: &str,
379        body: &str,
380        description: &str,
381    ) -> Result<(), MemoryError> {
382        let existing: Option<(i64,)> =
383            sqlx::query_as("SELECT id FROM skill_versions WHERE skill_name = ? LIMIT 1")
384                .bind(skill_name)
385                .fetch_optional(&self.pool)
386                .await?;
387
388        if existing.is_none() {
389            let id = self
390                .save_skill_version(skill_name, 1, body, description, "manual", None, None)
391                .await?;
392            self.activate_skill_version(skill_name, id).await?;
393        }
394        Ok(())
395    }
396
397    /// Load all versions for a skill, ordered by version number.
398    ///
399    /// # Errors
400    ///
401    /// Returns an error if the query fails.
402    pub async fn load_skill_versions(
403        &self,
404        skill_name: &str,
405    ) -> Result<Vec<SkillVersionRow>, MemoryError> {
406        let rows: Vec<SkillVersionTuple> = sqlx::query_as(
407            "SELECT id, skill_name, version, body, description, source, \
408                 is_active, success_count, failure_count, created_at \
409                 FROM skill_versions WHERE skill_name = ? ORDER BY version ASC",
410        )
411        .bind(skill_name)
412        .fetch_all(&self.pool)
413        .await?;
414
415        Ok(rows.into_iter().map(skill_version_from_tuple).collect())
416    }
417
418    /// Count auto-generated versions for a skill.
419    ///
420    /// # Errors
421    ///
422    /// Returns an error if the query fails.
423    pub async fn count_auto_versions(&self, skill_name: &str) -> Result<i64, MemoryError> {
424        let row: (i64,) = sqlx::query_as(
425            "SELECT COUNT(*) FROM skill_versions WHERE skill_name = ? AND source = 'auto'",
426        )
427        .bind(skill_name)
428        .fetch_one(&self.pool)
429        .await?;
430        Ok(row.0)
431    }
432
433    /// Delete oldest non-active auto versions exceeding max limit.
434    /// Returns the number of pruned versions.
435    ///
436    /// # Errors
437    ///
438    /// Returns an error if the delete fails.
439    pub async fn prune_skill_versions(
440        &self,
441        skill_name: &str,
442        max_versions: u32,
443    ) -> Result<u32, MemoryError> {
444        let result = sqlx::query(
445            "DELETE FROM skill_versions WHERE id IN (\
446                SELECT id FROM skill_versions \
447                WHERE skill_name = ? AND source = 'auto' AND is_active = 0 \
448                ORDER BY id ASC \
449                LIMIT max(0, (SELECT COUNT(*) FROM skill_versions \
450                    WHERE skill_name = ? AND source = 'auto') - ?)\
451            )",
452        )
453        .bind(skill_name)
454        .bind(skill_name)
455        .bind(max_versions)
456        .execute(&self.pool)
457        .await?;
458        Ok(u32::try_from(result.rows_affected()).unwrap_or(0))
459    }
460
461    /// Get the predecessor version for rollback.
462    ///
463    /// # Errors
464    ///
465    /// Returns an error if the query fails.
466    pub async fn predecessor_version(
467        &self,
468        version_id: i64,
469    ) -> Result<Option<SkillVersionRow>, MemoryError> {
470        let pred_id: Option<(Option<i64>,)> =
471            sqlx::query_as("SELECT predecessor_id FROM skill_versions WHERE id = ?")
472                .bind(version_id)
473                .fetch_optional(&self.pool)
474                .await?;
475
476        let Some((Some(pid),)) = pred_id else {
477            return Ok(None);
478        };
479
480        let row: Option<SkillVersionTuple> = sqlx::query_as(
481            "SELECT id, skill_name, version, body, description, source, \
482                 is_active, success_count, failure_count, created_at \
483                 FROM skill_versions WHERE id = ?",
484        )
485        .bind(pid)
486        .fetch_optional(&self.pool)
487        .await?;
488
489        Ok(row.map(skill_version_from_tuple))
490    }
491
492    /// Return the skill names for all currently active auto-generated versions.
493    ///
494    /// Used to check rollback eligibility at the start of each agent turn.
495    ///
496    /// # Errors
497    /// Returns [`MemoryError`] on `SQLite` query failure.
498    pub async fn list_active_auto_versions(&self) -> Result<Vec<String>, MemoryError> {
499        let rows: Vec<(String,)> = sqlx::query_as(
500            "SELECT skill_name FROM skill_versions WHERE is_active = 1 AND source = 'auto'",
501        )
502        .fetch_all(&self.pool)
503        .await?;
504        Ok(rows.into_iter().map(|(name,)| name).collect())
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    async fn test_store() -> SqliteStore {
513        SqliteStore::new(":memory:").await.unwrap()
514    }
515
516    #[tokio::test]
517    async fn record_skill_usage_increments() {
518        let store = test_store().await;
519
520        store.record_skill_usage(&["git"]).await.unwrap();
521        store.record_skill_usage(&["git"]).await.unwrap();
522
523        let usage = store.load_skill_usage().await.unwrap();
524        assert_eq!(usage.len(), 1);
525        assert_eq!(usage[0].skill_name, "git");
526        assert_eq!(usage[0].invocation_count, 2);
527    }
528
529    #[tokio::test]
530    async fn load_skill_usage_returns_all() {
531        let store = test_store().await;
532
533        store.record_skill_usage(&["git", "docker"]).await.unwrap();
534        store.record_skill_usage(&["git"]).await.unwrap();
535
536        let usage = store.load_skill_usage().await.unwrap();
537        assert_eq!(usage.len(), 2);
538        assert_eq!(usage[0].skill_name, "git");
539        assert_eq!(usage[0].invocation_count, 2);
540        assert_eq!(usage[1].skill_name, "docker");
541        assert_eq!(usage[1].invocation_count, 1);
542    }
543
544    #[tokio::test]
545    async fn migration_005_creates_tables() {
546        let store = test_store().await;
547        let pool = store.pool();
548
549        let versions: (i64,) = sqlx::query_as(
550            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='skill_versions'",
551        )
552        .fetch_one(pool)
553        .await
554        .unwrap();
555        assert_eq!(versions.0, 1);
556
557        let outcomes: (i64,) = sqlx::query_as(
558            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='skill_outcomes'",
559        )
560        .fetch_one(pool)
561        .await
562        .unwrap();
563        assert_eq!(outcomes.0, 1);
564    }
565
566    #[tokio::test]
567    async fn record_skill_outcome_inserts() {
568        let store = test_store().await;
569
570        store
571            .record_skill_outcome(
572                "git",
573                None,
574                Some(crate::types::ConversationId(1)),
575                "success",
576                None,
577                None,
578            )
579            .await
580            .unwrap();
581        store
582            .record_skill_outcome(
583                "git",
584                None,
585                Some(crate::types::ConversationId(1)),
586                "tool_failure",
587                Some("exit code 1"),
588                None,
589            )
590            .await
591            .unwrap();
592
593        let metrics = store.skill_metrics("git").await.unwrap().unwrap();
594        assert_eq!(metrics.total, 2);
595        assert_eq!(metrics.successes, 1);
596        assert_eq!(metrics.failures, 1);
597    }
598
599    #[tokio::test]
600    async fn skill_metrics_none_for_unknown() {
601        let store = test_store().await;
602        let m = store.skill_metrics("nonexistent").await.unwrap();
603        assert!(m.is_none());
604    }
605
606    #[tokio::test]
607    async fn load_skill_outcome_stats_grouped() {
608        let store = test_store().await;
609
610        store
611            .record_skill_outcome("git", None, None, "success", None, None)
612            .await
613            .unwrap();
614        store
615            .record_skill_outcome("git", None, None, "tool_failure", None, None)
616            .await
617            .unwrap();
618        store
619            .record_skill_outcome("docker", None, None, "success", None, None)
620            .await
621            .unwrap();
622
623        let stats = store.load_skill_outcome_stats().await.unwrap();
624        assert_eq!(stats.len(), 2);
625        assert_eq!(stats[0].skill_name, "git");
626        assert_eq!(stats[0].total, 2);
627        assert_eq!(stats[1].skill_name, "docker");
628        assert_eq!(stats[1].total, 1);
629    }
630
631    #[tokio::test]
632    async fn save_and_load_skill_version() {
633        let store = test_store().await;
634
635        let id = store
636            .save_skill_version("git", 1, "body v1", "Git helper", "manual", None, None)
637            .await
638            .unwrap();
639        assert!(id > 0);
640
641        store.activate_skill_version("git", id).await.unwrap();
642
643        let active = store.active_skill_version("git").await.unwrap().unwrap();
644        assert_eq!(active.version, 1);
645        assert_eq!(active.body, "body v1");
646        assert!(active.is_active);
647    }
648
649    #[tokio::test]
650    async fn activate_deactivates_previous() {
651        let store = test_store().await;
652
653        let v1 = store
654            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
655            .await
656            .unwrap();
657        store.activate_skill_version("git", v1).await.unwrap();
658
659        let v2 = store
660            .save_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
661            .await
662            .unwrap();
663        store.activate_skill_version("git", v2).await.unwrap();
664
665        let versions = store.load_skill_versions("git").await.unwrap();
666        assert_eq!(versions.len(), 2);
667        assert!(!versions[0].is_active);
668        assert!(versions[1].is_active);
669    }
670
671    #[tokio::test]
672    async fn next_skill_version_increments() {
673        let store = test_store().await;
674
675        let next = store.next_skill_version("git").await.unwrap();
676        assert_eq!(next, 1);
677
678        store
679            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
680            .await
681            .unwrap();
682        let next = store.next_skill_version("git").await.unwrap();
683        assert_eq!(next, 2);
684    }
685
686    #[tokio::test]
687    async fn last_improvement_time_returns_auto_only() {
688        let store = test_store().await;
689
690        store
691            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
692            .await
693            .unwrap();
694
695        let t = store.last_improvement_time("git").await.unwrap();
696        assert!(t.is_none());
697
698        store
699            .save_skill_version("git", 2, "v2", "desc", "auto", None, None)
700            .await
701            .unwrap();
702
703        let t = store.last_improvement_time("git").await.unwrap();
704        assert!(t.is_some());
705    }
706
707    #[tokio::test]
708    async fn ensure_skill_version_exists_idempotent() {
709        let store = test_store().await;
710
711        store
712            .ensure_skill_version_exists("git", "body", "Git helper")
713            .await
714            .unwrap();
715        store
716            .ensure_skill_version_exists("git", "body2", "Git helper 2")
717            .await
718            .unwrap();
719
720        let versions = store.load_skill_versions("git").await.unwrap();
721        assert_eq!(versions.len(), 1);
722        assert_eq!(versions[0].body, "body");
723    }
724
725    #[tokio::test]
726    async fn load_skill_versions_ordered() {
727        let store = test_store().await;
728
729        let v1 = store
730            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
731            .await
732            .unwrap();
733        store
734            .save_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
735            .await
736            .unwrap();
737
738        let versions = store.load_skill_versions("git").await.unwrap();
739        assert_eq!(versions.len(), 2);
740        assert_eq!(versions[0].version, 1);
741        assert_eq!(versions[1].version, 2);
742    }
743
744    #[tokio::test]
745    async fn count_auto_versions_only() {
746        let store = test_store().await;
747
748        store
749            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
750            .await
751            .unwrap();
752        store
753            .save_skill_version("git", 2, "v2", "desc", "auto", None, None)
754            .await
755            .unwrap();
756        store
757            .save_skill_version("git", 3, "v3", "desc", "auto", None, None)
758            .await
759            .unwrap();
760
761        let count = store.count_auto_versions("git").await.unwrap();
762        assert_eq!(count, 2);
763    }
764
765    #[tokio::test]
766    async fn prune_preserves_manual_and_active() {
767        let store = test_store().await;
768
769        let v1 = store
770            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
771            .await
772            .unwrap();
773        store.activate_skill_version("git", v1).await.unwrap();
774
775        for i in 2..=5 {
776            store
777                .save_skill_version("git", i, &format!("v{i}"), "desc", "auto", None, None)
778                .await
779                .unwrap();
780        }
781
782        let pruned = store.prune_skill_versions("git", 2).await.unwrap();
783        assert_eq!(pruned, 2);
784
785        let versions = store.load_skill_versions("git").await.unwrap();
786        assert!(versions.iter().any(|v| v.source == "manual"));
787        let auto_count = versions.iter().filter(|v| v.source == "auto").count();
788        assert_eq!(auto_count, 2);
789    }
790
791    #[tokio::test]
792    async fn predecessor_version_returns_parent() {
793        let store = test_store().await;
794
795        let v1 = store
796            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
797            .await
798            .unwrap();
799        let v2 = store
800            .save_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
801            .await
802            .unwrap();
803
804        let pred = store.predecessor_version(v2).await.unwrap().unwrap();
805        assert_eq!(pred.id, v1);
806        assert_eq!(pred.version, 1);
807    }
808
809    #[tokio::test]
810    async fn predecessor_version_none_for_root() {
811        let store = test_store().await;
812
813        let v1 = store
814            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
815            .await
816            .unwrap();
817
818        let pred = store.predecessor_version(v1).await.unwrap();
819        assert!(pred.is_none());
820    }
821
822    #[tokio::test]
823    async fn active_skill_version_none_for_unknown() {
824        let store = test_store().await;
825        let active = store.active_skill_version("nonexistent").await.unwrap();
826        assert!(active.is_none());
827    }
828
829    #[tokio::test]
830    async fn load_skill_outcome_stats_empty() {
831        let store = test_store().await;
832        let stats = store.load_skill_outcome_stats().await.unwrap();
833        assert!(stats.is_empty());
834    }
835
836    #[tokio::test]
837    async fn load_skill_versions_empty() {
838        let store = test_store().await;
839        let versions = store.load_skill_versions("nonexistent").await.unwrap();
840        assert!(versions.is_empty());
841    }
842
843    #[tokio::test]
844    async fn count_auto_versions_zero_for_unknown() {
845        let store = test_store().await;
846        let count = store.count_auto_versions("nonexistent").await.unwrap();
847        assert_eq!(count, 0);
848    }
849
850    #[tokio::test]
851    async fn prune_nothing_when_below_limit() {
852        let store = test_store().await;
853
854        store
855            .save_skill_version("git", 1, "v1", "desc", "auto", None, None)
856            .await
857            .unwrap();
858
859        let pruned = store.prune_skill_versions("git", 5).await.unwrap();
860        assert_eq!(pruned, 0);
861    }
862
863    #[tokio::test]
864    async fn record_skill_outcome_with_error_context() {
865        let store = test_store().await;
866
867        store
868            .record_skill_outcome(
869                "docker",
870                None,
871                Some(crate::types::ConversationId(1)),
872                "tool_failure",
873                Some("container not found"),
874                None,
875            )
876            .await
877            .unwrap();
878
879        let metrics = store.skill_metrics("docker").await.unwrap().unwrap();
880        assert_eq!(metrics.total, 1);
881        assert_eq!(metrics.failures, 1);
882    }
883
884    #[tokio::test]
885    async fn save_skill_version_with_error_context() {
886        let store = test_store().await;
887
888        let id = store
889            .save_skill_version(
890                "git",
891                1,
892                "improved body",
893                "Git helper",
894                "auto",
895                Some("exit code 128"),
896                None,
897            )
898            .await
899            .unwrap();
900        assert!(id > 0);
901    }
902
903    #[tokio::test]
904    async fn record_skill_outcomes_batch_resolves_version_id() {
905        let store = test_store().await;
906
907        let vid = store
908            .save_skill_version("git", 1, "body", "desc", "manual", None, None)
909            .await
910            .unwrap();
911        store.activate_skill_version("git", vid).await.unwrap();
912
913        store
914            .record_skill_outcomes_batch(
915                &["git".to_string()],
916                None,
917                "tool_failure",
918                Some("exit code 1"),
919                Some("exit_nonzero"),
920            )
921            .await
922            .unwrap();
923
924        let pool = store.pool();
925        let row: (Option<i64>, Option<String>) =
926            sqlx::query_as("SELECT version_id, outcome_detail FROM skill_outcomes WHERE skill_name = 'git' LIMIT 1")
927                .fetch_one(pool)
928                .await
929                .unwrap();
930        assert_eq!(
931            row.0,
932            Some(vid),
933            "version_id should be resolved to active version"
934        );
935        assert_eq!(row.1.as_deref(), Some("exit_nonzero"));
936    }
937
938    #[tokio::test]
939    async fn record_skill_outcome_stores_outcome_detail() {
940        let store = test_store().await;
941
942        store
943            .record_skill_outcome("docker", None, None, "tool_failure", None, Some("timeout"))
944            .await
945            .unwrap();
946
947        let pool = store.pool();
948        let row: (Option<String>,) = sqlx::query_as(
949            "SELECT outcome_detail FROM skill_outcomes WHERE skill_name = 'docker' LIMIT 1",
950        )
951        .fetch_one(pool)
952        .await
953        .unwrap();
954        assert_eq!(row.0.as_deref(), Some("timeout"));
955    }
956}