Skip to main content

toolhub_storage/
scores.rs

1//! `tool_scores` table accessors.
2//!
3//! Phase 3: read-only access — `tool_scores` is empty until Phase 4 lands
4//! the heuristic outcome scorer.
5
6use rusqlite::Connection;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ScoreRow {
11    pub tool_id: String,
12    pub success_rate: Option<f64>,
13    pub sample_size: Option<i64>,
14    pub avg_cost_usd: Option<f64>,
15    pub median_duration_ms: Option<i64>,
16    pub score_updated_at: Option<String>,
17}
18
19pub fn list(conn: &Connection, tool_id: Option<&str>) -> anyhow::Result<Vec<ScoreRow>> {
20    let (sql, params) = match tool_id {
21        Some(id) => (
22            "SELECT tool_id, success_rate, sample_size, avg_cost_usd,
23                    median_duration_ms, score_updated_at
24             FROM tool_scores WHERE tool_id = ?",
25            vec![id.to_string()],
26        ),
27        None => (
28            "SELECT tool_id, success_rate, sample_size, avg_cost_usd,
29                    median_duration_ms, score_updated_at
30             FROM tool_scores ORDER BY tool_id",
31            vec![],
32        ),
33    };
34    let mut stmt = conn.prepare(sql)?;
35    let param_refs: Vec<&dyn rusqlite::ToSql> =
36        params.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
37    let rows = stmt
38        .query_map(param_refs.as_slice(), |row| {
39            Ok(ScoreRow {
40                tool_id: row.get(0)?,
41                success_rate: row.get(1)?,
42                sample_size: row.get(2)?,
43                avg_cost_usd: row.get(3)?,
44                median_duration_ms: row.get(4)?,
45                score_updated_at: row.get(5)?,
46            })
47        })?
48        .collect::<Result<Vec<_>, _>>()?;
49    Ok(rows)
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55    use crate::open;
56    use rusqlite::params;
57
58    #[test]
59    fn list_empty_table_returns_empty() {
60        let dir = tempfile::tempdir().unwrap();
61        let conn = open(&dir.path().join("t.sqlite")).unwrap();
62        let rows = list(&conn, None).unwrap();
63        assert!(rows.is_empty());
64        let rows = list(&conn, Some("skill:nonexistent")).unwrap();
65        assert!(rows.is_empty());
66    }
67
68    #[test]
69    fn list_returns_inserted_rows_filtered_by_id() {
70        let dir = tempfile::tempdir().unwrap();
71        let conn = open(&dir.path().join("t.sqlite")).unwrap();
72        // tool_scores has FK to tools(id); insert a tool first.
73        conn.execute(
74            "INSERT INTO tools (id, type, name, triggers, examples, requires,
75                                enabled, added_at, last_seen_at)
76             VALUES (?, 'skill', 'X', '[]', '[]', '[]', 1,
77                     '2026-05-03T00:00:00+00:00', '2026-05-03T00:00:00+00:00')",
78            params!["skill:x"],
79        )
80        .unwrap();
81        conn.execute(
82            "INSERT INTO tool_scores
83             (tool_id, success_rate, sample_size, avg_cost_usd,
84              median_duration_ms, score_updated_at)
85             VALUES (?, 0.8, 10, 0.04, 250, '2026-05-03T00:00:00+00:00')",
86            params!["skill:x"],
87        )
88        .unwrap();
89        let rows = list(&conn, Some("skill:x")).unwrap();
90        assert_eq!(rows.len(), 1);
91        assert_eq!(rows[0].sample_size, Some(10));
92    }
93}