Skip to main content

reflect_store/
sqlite.rs

1use std::sync::Mutex;
2
3use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use rusqlite::{params, Connection, OptionalExtension};
6use uuid::Uuid;
7
8use reflect_core::error::{ReflectError, Result};
9use reflect_core::storage::Storage;
10use reflect_core::types::{
11    ErrorPattern, EvalError, EvalSignal, Outcome, OutcomeCounts, Reflection, ReflectionStats,
12    ScoredReflection, TagCount, Trend,
13};
14
15pub struct SqliteStorage {
16    conn: Mutex<Connection>,
17}
18
19impl SqliteStorage {
20    pub fn open(path: &str) -> Result<Self> {
21        let conn =
22            Connection::open(path).map_err(|e| ReflectError::Storage(e.to_string()))?;
23        let storage = Self {
24            conn: Mutex::new(conn),
25        };
26        storage.init_schema()?;
27        Ok(storage)
28    }
29
30    pub fn open_in_memory() -> Result<Self> {
31        let conn =
32            Connection::open_in_memory().map_err(|e| ReflectError::Storage(e.to_string()))?;
33        let storage = Self {
34            conn: Mutex::new(conn),
35        };
36        storage.init_schema()?;
37        Ok(storage)
38    }
39
40    fn init_schema(&self) -> Result<()> {
41        let conn = self.conn.lock().map_err(|e| ReflectError::Storage(e.to_string()))?;
42        conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
43            .map_err(|e| ReflectError::Storage(e.to_string()))?;
44        conn.execute_batch(include_str!("schema.sql"))
45            .map_err(|e| ReflectError::Storage(e.to_string()))?;
46        Ok(())
47    }
48
49    fn map_err(e: rusqlite::Error) -> ReflectError {
50        ReflectError::Storage(e.to_string())
51    }
52}
53
54fn parse_datetime(s: &str) -> Result<DateTime<Utc>> {
55    DateTime::parse_from_rfc3339(s)
56        .map(|dt| dt.with_timezone(&Utc))
57        .map_err(|e| ReflectError::Storage(format!("invalid datetime: {e}")))
58}
59
60fn row_to_reflection(
61    row: &rusqlite::Row,
62    eval_signals: Vec<EvalSignal>,
63) -> std::result::Result<Reflection, rusqlite::Error> {
64    let id_str: String = row.get(0)?;
65    let id = Uuid::parse_str(&id_str)
66        .map_err(|e| rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e)))?;
67    let task_description: String = row.get(1)?;
68    let draft: String = row.get(2)?;
69    let critique: String = row.get(3)?;
70    let lesson: String = row.get(4)?;
71    let outcome_str: String = row.get(5)?;
72    let outcome: Outcome = outcome_str
73        .parse()
74        .map_err(|e: String| rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e))))?;
75    let pattern_id: Option<String> = row.get(6)?;
76    let tags_json: String = row.get(7)?;
77    let tags: Vec<String> = serde_json::from_str(&tags_json).unwrap_or_default();
78    let confidence: f64 = row.get(8)?;
79    let validation_count: i32 = row.get(9)?;
80    let contradiction_count: i32 = row.get(10)?;
81    let created_at_str: String = row.get(11)?;
82    let last_recalled_str: Option<String> = row.get(12)?;
83
84    let created_at = DateTime::parse_from_rfc3339(&created_at_str)
85        .map(|dt| dt.with_timezone(&Utc))
86        .map_err(|e| rusqlite::Error::FromSqlConversionFailure(11, rusqlite::types::Type::Text, Box::new(e)))?;
87    let last_recalled = last_recalled_str
88        .map(|s| {
89            DateTime::parse_from_rfc3339(&s)
90                .map(|dt| dt.with_timezone(&Utc))
91        })
92        .transpose()
93        .map_err(|e| rusqlite::Error::FromSqlConversionFailure(12, rusqlite::types::Type::Text, Box::new(e)))?;
94
95    Ok(Reflection {
96        id,
97        task_description,
98        draft,
99        error_signals: eval_signals,
100        critique,
101        lesson,
102        outcome,
103        pattern_id,
104        tags,
105        confidence: confidence as f32,
106        validation_count: validation_count as u32,
107        contradiction_count: contradiction_count as u32,
108        created_at,
109        last_recalled,
110    })
111}
112
113fn load_eval_signals(
114    conn: &Connection,
115    reflection_id: &str,
116) -> std::result::Result<Vec<EvalSignal>, rusqlite::Error> {
117    let mut stmt = conn.prepare(
118        "SELECT evaluator, passed, summary, errors_json FROM eval_signals WHERE reflection_id = ?1",
119    )?;
120    let signals = stmt
121        .query_map(params![reflection_id], |row| {
122            let evaluator: String = row.get(0)?;
123            let passed: bool = row.get(1)?;
124            let summary: String = row.get(2)?;
125            let errors_json: String = row.get(3)?;
126            let errors: Vec<EvalError> = serde_json::from_str(&errors_json).unwrap_or_default();
127            Ok(EvalSignal {
128                evaluator,
129                passed,
130                summary,
131                errors,
132            })
133        })?
134        .collect::<std::result::Result<Vec<_>, _>>()?;
135    Ok(signals)
136}
137
138#[async_trait]
139impl Storage for SqliteStorage {
140    async fn store_reflection(&self, reflection: &Reflection) -> Result<()> {
141        let conn = self.conn.lock().map_err(|e| ReflectError::Storage(e.to_string()))?;
142        let id_str = reflection.id.to_string();
143        let tags_json =
144            serde_json::to_string(&reflection.tags).map_err(|e| ReflectError::Storage(e.to_string()))?;
145        let created_at_str = reflection.created_at.to_rfc3339();
146        let last_recalled_str = reflection.last_recalled.map(|dt| dt.to_rfc3339());
147
148        conn.execute(
149            "INSERT INTO reflections (id, task_description, draft, critique, lesson, outcome, pattern_id, tags, confidence, validation_count, contradiction_count, created_at, last_recalled)
150             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
151            params![
152                id_str,
153                reflection.task_description,
154                reflection.draft,
155                reflection.critique,
156                reflection.lesson,
157                reflection.outcome.as_str(),
158                reflection.pattern_id,
159                tags_json,
160                reflection.confidence as f64,
161                reflection.validation_count as i32,
162                reflection.contradiction_count as i32,
163                created_at_str,
164                last_recalled_str,
165            ],
166        )
167        .map_err(SqliteStorage::map_err)?;
168
169        for signal in &reflection.error_signals {
170            let errors_json = serde_json::to_string(&signal.errors)
171                .map_err(|e| ReflectError::Storage(e.to_string()))?;
172            conn.execute(
173                "INSERT INTO eval_signals (reflection_id, evaluator, passed, summary, errors_json)
174                 VALUES (?1, ?2, ?3, ?4, ?5)",
175                params![id_str, signal.evaluator, signal.passed, signal.summary, errors_json],
176            )
177            .map_err(SqliteStorage::map_err)?;
178        }
179
180        Ok(())
181    }
182
183    async fn get_reflection(&self, id: &Uuid) -> Result<Option<Reflection>> {
184        let conn = self.conn.lock().map_err(|e| ReflectError::Storage(e.to_string()))?;
185        let id_str = id.to_string();
186
187        let signals = load_eval_signals(&conn, &id_str).map_err(SqliteStorage::map_err)?;
188
189        let result = conn
190            .query_row(
191                "SELECT id, task_description, draft, critique, lesson, outcome, pattern_id, tags, confidence, validation_count, contradiction_count, created_at, last_recalled
192                 FROM reflections WHERE id = ?1",
193                params![id_str],
194                |row| row_to_reflection(row, signals.clone()),
195            )
196            .optional()
197            .map_err(SqliteStorage::map_err)?;
198
199        Ok(result)
200    }
201
202    async fn delete_reflection(&self, id: &Uuid) -> Result<bool> {
203        let conn = self.conn.lock().map_err(|e| ReflectError::Storage(e.to_string()))?;
204        let id_str = id.to_string();
205
206        // Delete eval_signals first (or rely on CASCADE)
207        conn.execute(
208            "DELETE FROM eval_signals WHERE reflection_id = ?1",
209            params![id_str],
210        )
211        .map_err(SqliteStorage::map_err)?;
212
213        // We need to manually update FTS before deleting the row, since the trigger
214        // reads old.* values, but we also need to handle the content sync.
215        // Actually the trigger handles it — just delete.
216        let rows = conn
217            .execute("DELETE FROM reflections WHERE id = ?1", params![id_str])
218            .map_err(SqliteStorage::map_err)?;
219
220        Ok(rows > 0)
221    }
222
223    async fn search_reflections(
224        &self,
225        query: &str,
226        tags: &[String],
227        limit: usize,
228    ) -> Result<Vec<ScoredReflection>> {
229        let conn = self.conn.lock().map_err(|e| ReflectError::Storage(e.to_string()))?;
230
231        let mut stmt = conn
232            .prepare(
233                "SELECT r.id, r.task_description, r.draft, r.critique, r.lesson, r.outcome,
234                        r.pattern_id, r.tags, r.confidence, r.validation_count,
235                        r.contradiction_count, r.created_at, r.last_recalled, -fts.rank as score
236                 FROM reflections_fts fts
237                 JOIN reflections r ON r.rowid = fts.rowid
238                 WHERE reflections_fts MATCH ?1
239                 ORDER BY rank
240                 LIMIT ?2",
241            )
242            .map_err(SqliteStorage::map_err)?;
243
244        let results = stmt
245            .query_map(params![query, limit as i64], |row| {
246                let score: f64 = row.get(13)?;
247                let reflection = row_to_reflection(row, vec![])?;
248                Ok(ScoredReflection {
249                    reflection,
250                    relevance_score: score,
251                })
252            })
253            .map_err(SqliteStorage::map_err)?
254            .collect::<std::result::Result<Vec<_>, _>>()
255            .map_err(SqliteStorage::map_err)?;
256
257        // Load eval signals for each result and apply tag filter
258        let mut filtered = Vec::new();
259        for mut sr in results {
260            // Load eval signals
261            let signals =
262                load_eval_signals(&conn, &sr.reflection.id.to_string()).map_err(SqliteStorage::map_err)?;
263            sr.reflection.error_signals = signals;
264
265            // Tag filter: if tags provided, reflection must contain all of them
266            if !tags.is_empty() {
267                let has_all_tags = tags.iter().all(|tag| sr.reflection.tags.contains(tag));
268                if !has_all_tags {
269                    continue;
270                }
271            }
272            filtered.push(sr);
273        }
274
275        Ok(filtered)
276    }
277
278    async fn upsert_pattern(&self, pattern: &ErrorPattern) -> Result<()> {
279        let conn = self.conn.lock().map_err(|e| ReflectError::Storage(e.to_string()))?;
280
281        conn.execute(
282            "INSERT INTO error_patterns (id, category, description, occurrences, first_seen, last_seen)
283             VALUES (?1, ?2, ?3, ?4, ?5, ?6)
284             ON CONFLICT(id) DO UPDATE SET
285                 category = ?2,
286                 description = ?3,
287                 occurrences = ?4,
288                 first_seen = ?5,
289                 last_seen = ?6",
290            params![
291                pattern.id,
292                pattern.category,
293                pattern.description,
294                pattern.occurrences as i32,
295                pattern.first_seen.to_rfc3339(),
296                pattern.last_seen.to_rfc3339(),
297            ],
298        )
299        .map_err(SqliteStorage::map_err)?;
300
301        Ok(())
302    }
303
304    async fn get_pattern(&self, id: &str) -> Result<Option<ErrorPattern>> {
305        let conn = self.conn.lock().map_err(|e| ReflectError::Storage(e.to_string()))?;
306
307        let result = conn
308            .query_row(
309                "SELECT id, category, description, occurrences, first_seen, last_seen
310                 FROM error_patterns WHERE id = ?1",
311                params![id],
312                |row| {
313                    let id: String = row.get(0)?;
314                    let category: String = row.get(1)?;
315                    let description: String = row.get(2)?;
316                    let occurrences: i32 = row.get(3)?;
317                    let first_seen_str: String = row.get(4)?;
318                    let last_seen_str: String = row.get(5)?;
319                    Ok((id, category, description, occurrences, first_seen_str, last_seen_str))
320                },
321            )
322            .optional()
323            .map_err(SqliteStorage::map_err)?;
324
325        match result {
326            None => Ok(None),
327            Some((id, category, description, occurrences, first_seen_str, last_seen_str)) => {
328                let first_seen = parse_datetime(&first_seen_str)?;
329                let last_seen = parse_datetime(&last_seen_str)?;
330                Ok(Some(ErrorPattern {
331                    id,
332                    category,
333                    description,
334                    occurrences: occurrences as u32,
335                    first_seen,
336                    last_seen,
337                    reflection_ids: vec![],
338                    trend: Trend::Stable,
339                }))
340            }
341        }
342    }
343
344    async fn list_patterns(&self, min_occurrences: u32, limit: usize) -> Result<Vec<ErrorPattern>> {
345        let conn = self.conn.lock().map_err(|e| ReflectError::Storage(e.to_string()))?;
346
347        let mut stmt = conn
348            .prepare(
349                "SELECT id, category, description, occurrences, first_seen, last_seen
350                 FROM error_patterns
351                 WHERE occurrences >= ?1
352                 ORDER BY occurrences DESC
353                 LIMIT ?2",
354            )
355            .map_err(SqliteStorage::map_err)?;
356
357        let rows = stmt
358            .query_map(params![min_occurrences as i32, limit as i64], |row| {
359                let id: String = row.get(0)?;
360                let category: String = row.get(1)?;
361                let description: String = row.get(2)?;
362                let occurrences: i32 = row.get(3)?;
363                let first_seen_str: String = row.get(4)?;
364                let last_seen_str: String = row.get(5)?;
365                Ok((id, category, description, occurrences, first_seen_str, last_seen_str))
366            })
367            .map_err(SqliteStorage::map_err)?
368            .collect::<std::result::Result<Vec<_>, _>>()
369            .map_err(SqliteStorage::map_err)?;
370
371        let mut patterns = Vec::new();
372        for (id, category, description, occurrences, first_seen_str, last_seen_str) in rows {
373            let first_seen = parse_datetime(&first_seen_str)?;
374            let last_seen = parse_datetime(&last_seen_str)?;
375            patterns.push(ErrorPattern {
376                id,
377                category,
378                description,
379                occurrences: occurrences as u32,
380                first_seen,
381                last_seen,
382                reflection_ids: vec![],
383                trend: Trend::Stable,
384            });
385        }
386
387        Ok(patterns)
388    }
389
390    async fn get_stats(&self) -> Result<ReflectionStats> {
391        let conn = self.conn.lock().map_err(|e| ReflectError::Storage(e.to_string()))?;
392
393        let total_reflections: u64 = conn
394            .query_row("SELECT COUNT(*) FROM reflections", [], |row| row.get(0))
395            .map_err(SqliteStorage::map_err)?;
396
397        let success: u64 = conn
398            .query_row(
399                "SELECT COUNT(*) FROM reflections WHERE outcome = 'success'",
400                [],
401                |row| row.get(0),
402            )
403            .map_err(SqliteStorage::map_err)?;
404
405        let failure: u64 = conn
406            .query_row(
407                "SELECT COUNT(*) FROM reflections WHERE outcome = 'failure'",
408                [],
409                |row| row.get(0),
410            )
411            .map_err(SqliteStorage::map_err)?;
412
413        let partial: u64 = conn
414            .query_row(
415                "SELECT COUNT(*) FROM reflections WHERE outcome = 'partial'",
416                [],
417                |row| row.get(0),
418            )
419            .map_err(SqliteStorage::map_err)?;
420
421        let avg_confidence: f64 = conn
422            .query_row(
423                "SELECT COALESCE(AVG(confidence), 0.0) FROM reflections",
424                [],
425                |row| row.get(0),
426            )
427            .map_err(SqliteStorage::map_err)?;
428
429        let reflections_this_week: u64 = conn
430            .query_row(
431                "SELECT COUNT(*) FROM reflections WHERE created_at >= datetime('now', '-7 days')",
432                [],
433                |row| row.get(0),
434            )
435            .map_err(SqliteStorage::map_err)?;
436
437        // Top tags using json_each
438        let mut tag_stmt = conn
439            .prepare(
440                "SELECT value, COUNT(*) as cnt
441                 FROM reflections, json_each(reflections.tags)
442                 GROUP BY value
443                 ORDER BY cnt DESC
444                 LIMIT 10",
445            )
446            .map_err(SqliteStorage::map_err)?;
447
448        let top_tags = tag_stmt
449            .query_map([], |row| {
450                let tag: String = row.get(0)?;
451                let count: u64 = row.get(1)?;
452                Ok(TagCount { tag, count })
453            })
454            .map_err(SqliteStorage::map_err)?
455            .collect::<std::result::Result<Vec<_>, _>>()
456            .map_err(SqliteStorage::map_err)?;
457
458        // Top patterns
459        let mut pat_stmt = conn
460            .prepare(
461                "SELECT id, category, description, occurrences, first_seen, last_seen
462                 FROM error_patterns
463                 ORDER BY occurrences DESC
464                 LIMIT 5",
465            )
466            .map_err(SqliteStorage::map_err)?;
467
468        let pattern_rows = pat_stmt
469            .query_map([], |row| {
470                let id: String = row.get(0)?;
471                let category: String = row.get(1)?;
472                let description: String = row.get(2)?;
473                let occurrences: i32 = row.get(3)?;
474                let first_seen_str: String = row.get(4)?;
475                let last_seen_str: String = row.get(5)?;
476                Ok((id, category, description, occurrences, first_seen_str, last_seen_str))
477            })
478            .map_err(SqliteStorage::map_err)?
479            .collect::<std::result::Result<Vec<_>, _>>()
480            .map_err(SqliteStorage::map_err)?;
481
482        let mut top_patterns = Vec::new();
483        for (id, category, description, occurrences, first_seen_str, last_seen_str) in pattern_rows
484        {
485            let first_seen = parse_datetime(&first_seen_str)?;
486            let last_seen = parse_datetime(&last_seen_str)?;
487            top_patterns.push(ErrorPattern {
488                id,
489                category,
490                description,
491                occurrences: occurrences as u32,
492                first_seen,
493                last_seen,
494                reflection_ids: vec![],
495                trend: Trend::Stable,
496            });
497        }
498
499        Ok(ReflectionStats {
500            total_reflections,
501            by_outcome: OutcomeCounts {
502                success,
503                failure,
504                partial,
505            },
506            top_patterns,
507            top_tags,
508            avg_confidence,
509            reflections_this_week,
510        })
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use chrono::Utc;
518    use reflect_core::types::{EvalError, Outcome, Severity};
519    use uuid::Uuid;
520
521    fn test_reflection(task: &str, lesson: &str, tags: Vec<String>) -> Reflection {
522        Reflection {
523            id: Uuid::now_v7(),
524            task_description: task.into(),
525            draft: "let x = input.parse().unwrap();".into(),
526            error_signals: vec![],
527            critique: "used unwrap on user input".into(),
528            lesson: lesson.into(),
529            outcome: Outcome::Failure,
530            pattern_id: None,
531            tags,
532            confidence: 0.5,
533            validation_count: 0,
534            contradiction_count: 0,
535            created_at: Utc::now(),
536            last_recalled: None,
537        }
538    }
539
540    fn storage() -> SqliteStorage {
541        SqliteStorage::open_in_memory().expect("failed to open in-memory db")
542    }
543
544    #[tokio::test]
545    async fn store_and_get_reflection() {
546        let store = storage();
547        let r = test_reflection("parse user date", "use Result instead of unwrap", vec!["rust".into()]);
548        let id = r.id;
549
550        store.store_reflection(&r).await.unwrap();
551        let fetched = store.get_reflection(&id).await.unwrap().expect("should exist");
552
553        assert_eq!(fetched.id, id);
554        assert_eq!(fetched.task_description, "parse user date");
555        assert_eq!(fetched.lesson, "use Result instead of unwrap");
556        assert_eq!(fetched.tags, vec!["rust".to_string()]);
557        assert_eq!(fetched.outcome, Outcome::Failure);
558    }
559
560    #[tokio::test]
561    async fn delete_reflection() {
562        let store = storage();
563        let r = test_reflection("delete test", "lesson", vec![]);
564        let id = r.id;
565
566        store.store_reflection(&r).await.unwrap();
567        let deleted = store.delete_reflection(&id).await.unwrap();
568        assert!(deleted);
569
570        let fetched = store.get_reflection(&id).await.unwrap();
571        assert!(fetched.is_none());
572
573        let deleted_again = store.delete_reflection(&id).await.unwrap();
574        assert!(!deleted_again);
575    }
576
577    #[tokio::test]
578    async fn search_fts() {
579        let store = storage();
580        let r1 = test_reflection("parse user date input", "use chrono for dates", vec!["rust".into()]);
581        let r2 = test_reflection("handle network timeout", "add retry logic", vec!["networking".into()]);
582
583        store.store_reflection(&r1).await.unwrap();
584        store.store_reflection(&r2).await.unwrap();
585
586        let results = store.search_reflections("date", &[], 10).await.unwrap();
587        assert_eq!(results.len(), 1);
588        assert_eq!(results[0].reflection.task_description, "parse user date input");
589        assert!(results[0].relevance_score > 0.0);
590    }
591
592    #[tokio::test]
593    async fn search_by_tags() {
594        let store = storage();
595        let r1 = test_reflection("task one", "lesson one", vec!["rust".into(), "error-handling".into()]);
596        let r2 = test_reflection("task two", "lesson two", vec!["python".into()]);
597
598        store.store_reflection(&r1).await.unwrap();
599        store.store_reflection(&r2).await.unwrap();
600
601        // Search with tag filter — FTS query matches both (via "lesson" in critique/lesson)
602        let results = store
603            .search_reflections("unwrap", &["rust".into()], 10)
604            .await
605            .unwrap();
606
607        assert!(results.len() >= 1);
608        assert!(results.iter().all(|sr| sr.reflection.tags.contains(&"rust".to_string())));
609    }
610
611    #[tokio::test]
612    async fn upsert_and_list_patterns() {
613        let store = storage();
614        let now = Utc::now();
615
616        let pattern = ErrorPattern {
617            id: "unwrap-on-input".into(),
618            category: "error-handling".into(),
619            description: "Using unwrap on user input".into(),
620            occurrences: 3,
621            first_seen: now,
622            last_seen: now,
623            reflection_ids: vec![],
624            trend: Trend::Stable,
625        };
626
627        store.upsert_pattern(&pattern).await.unwrap();
628
629        let fetched = store.get_pattern("unwrap-on-input").await.unwrap().expect("should exist");
630        assert_eq!(fetched.occurrences, 3);
631        assert_eq!(fetched.category, "error-handling");
632
633        // Upsert with increased occurrences
634        let updated = ErrorPattern {
635            occurrences: 5,
636            ..pattern.clone()
637        };
638        store.upsert_pattern(&updated).await.unwrap();
639        let fetched2 = store.get_pattern("unwrap-on-input").await.unwrap().unwrap();
640        assert_eq!(fetched2.occurrences, 5);
641
642        // List with min_occurrences filter
643        let all = store.list_patterns(1, 10).await.unwrap();
644        assert_eq!(all.len(), 1);
645
646        let filtered = store.list_patterns(10, 10).await.unwrap();
647        assert!(filtered.is_empty());
648    }
649
650    #[tokio::test]
651    async fn store_with_eval_signals() {
652        let store = storage();
653        let mut r = test_reflection("eval test", "check signals", vec![]);
654        r.error_signals = vec![EvalSignal {
655            evaluator: "cargo_test".into(),
656            passed: false,
657            summary: "1 test failed".into(),
658            errors: vec![EvalError {
659                file: Some("src/main.rs".into()),
660                line: Some(42),
661                column: None,
662                code: None,
663                message: "panicked at unwrap".into(),
664                severity: Severity::Error,
665            }],
666        }];
667        let id = r.id;
668
669        store.store_reflection(&r).await.unwrap();
670        let fetched = store.get_reflection(&id).await.unwrap().unwrap();
671
672        assert_eq!(fetched.error_signals.len(), 1);
673        assert_eq!(fetched.error_signals[0].evaluator, "cargo_test");
674        assert!(!fetched.error_signals[0].passed);
675        assert_eq!(fetched.error_signals[0].errors.len(), 1);
676        assert_eq!(fetched.error_signals[0].errors[0].line, Some(42));
677        assert_eq!(fetched.error_signals[0].errors[0].message, "panicked at unwrap");
678    }
679
680    #[tokio::test]
681    async fn get_stats() {
682        let store = storage();
683
684        let r1 = test_reflection("task 1", "lesson 1", vec!["rust".into(), "error-handling".into()]);
685        let mut r2 = test_reflection("task 2", "lesson 2", vec!["rust".into()]);
686        r2.outcome = Outcome::Success;
687        r2.confidence = 0.8;
688
689        store.store_reflection(&r1).await.unwrap();
690        store.store_reflection(&r2).await.unwrap();
691
692        let stats = store.get_stats().await.unwrap();
693        assert_eq!(stats.total_reflections, 2);
694        assert_eq!(stats.by_outcome.failure, 1);
695        assert_eq!(stats.by_outcome.success, 1);
696        assert_eq!(stats.by_outcome.partial, 0);
697        assert!((stats.avg_confidence - 0.65).abs() < 0.01);
698        assert!(stats.top_tags.iter().any(|t| t.tag == "rust" && t.count == 2));
699    }
700}