Skip to main content

toolhub_storage/
tools.rs

1use anyhow::{Context, anyhow};
2use chrono::{DateTime, Utc};
3use rusqlite::{Connection, params};
4use toolhub_core::tool::{ToolMeta, ToolType};
5
6fn type_to_str(t: ToolType) -> &'static str {
7    match t {
8        ToolType::Skill => "skill",
9        ToolType::Plugin => "plugin",
10        ToolType::Mcp => "mcp",
11        ToolType::Cli => "cli",
12        ToolType::Doc => "doc",
13    }
14}
15
16fn type_from_str(s: &str) -> anyhow::Result<ToolType> {
17    Ok(match s {
18        "skill" => ToolType::Skill,
19        "plugin" => ToolType::Plugin,
20        "mcp" => ToolType::Mcp,
21        "cli" => ToolType::Cli,
22        "doc" => ToolType::Doc,
23        other => return Err(anyhow!("unknown tool type {other:?}")),
24    })
25}
26
27fn parse_ts(s: &str) -> anyhow::Result<DateTime<Utc>> {
28    Ok(DateTime::parse_from_rfc3339(s)
29        .with_context(|| format!("parse RFC3339 timestamp {s:?}"))?
30        .with_timezone(&Utc))
31}
32
33pub fn upsert(conn: &Connection, m: &ToolMeta) -> anyhow::Result<()> {
34    let triggers = serde_json::to_string(&m.triggers)?;
35    let examples = serde_json::to_string(&m.examples)?;
36    let requires = serde_json::to_string(&m.requires)?;
37    let added = m.added_at.to_rfc3339();
38    let seen = m.last_seen_at.to_rfc3339();
39    let last_used = m.last_used_at.map(|d| d.to_rfc3339());
40    conn.execute(
41        "INSERT INTO tools (
42            id, type, name, source_repo, install_path, description, long_description,
43            category, triggers, examples, invocation, requires, enabled,
44            added_at, last_seen_at, last_used_at
45         ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
46         ON CONFLICT(id) DO UPDATE SET
47            type             = excluded.type,
48            name             = excluded.name,
49            source_repo      = excluded.source_repo,
50            install_path     = excluded.install_path,
51            description      = excluded.description,
52            long_description = excluded.long_description,
53            category         = excluded.category,
54            triggers         = excluded.triggers,
55            examples         = excluded.examples,
56            invocation       = excluded.invocation,
57            requires         = excluded.requires,
58            enabled          = excluded.enabled,
59            last_seen_at     = excluded.last_seen_at",
60        params![
61            m.id,
62            type_to_str(m.r#type),
63            m.name,
64            m.source_repo,
65            m.install_path,
66            m.description,
67            m.long_description,
68            m.category,
69            triggers,
70            examples,
71            m.invocation,
72            requires,
73            m.enabled as i64,
74            added,
75            seen,
76            last_used,
77        ],
78    )?;
79    Ok(())
80}
81
82pub fn list_all(conn: &Connection) -> anyhow::Result<Vec<ToolMeta>> {
83    let mut stmt = conn.prepare(
84        "SELECT id, type, name, source_repo, install_path, description, long_description,
85                category, triggers, examples, invocation, requires, enabled,
86                added_at, last_seen_at, last_used_at
87         FROM tools ORDER BY type, name",
88    )?;
89    let rows = stmt
90        .query_map([], |row| {
91            Ok((
92                row.get::<_, String>(0)?,
93                row.get::<_, String>(1)?,
94                row.get::<_, String>(2)?,
95                row.get::<_, Option<String>>(3)?,
96                row.get::<_, Option<String>>(4)?,
97                row.get::<_, Option<String>>(5)?,
98                row.get::<_, Option<String>>(6)?,
99                row.get::<_, Option<String>>(7)?,
100                row.get::<_, String>(8)?,
101                row.get::<_, String>(9)?,
102                row.get::<_, Option<String>>(10)?,
103                row.get::<_, String>(11)?,
104                row.get::<_, i64>(12)?,
105                row.get::<_, String>(13)?,
106                row.get::<_, String>(14)?,
107                row.get::<_, Option<String>>(15)?,
108            ))
109        })?
110        .collect::<Result<Vec<_>, _>>()?;
111
112    let mut out = Vec::with_capacity(rows.len());
113    for r in rows {
114        out.push(ToolMeta {
115            id: r.0,
116            r#type: type_from_str(&r.1)?,
117            name: r.2,
118            source_repo: r.3,
119            install_path: r.4,
120            description: r.5,
121            long_description: r.6,
122            category: r.7,
123            triggers: serde_json::from_str(&r.8)?,
124            examples: serde_json::from_str(&r.9)?,
125            invocation: r.10,
126            requires: serde_json::from_str(&r.11)?,
127            enabled: r.12 != 0,
128            added_at: parse_ts(&r.13)?,
129            last_seen_at: parse_ts(&r.14)?,
130            last_used_at: r.15.as_deref().map(parse_ts).transpose()?,
131        });
132    }
133    Ok(out)
134}
135
136/// Delete every tool whose `source_repo` exactly matches `location`.
137/// Also clears matching rows from `tool_embeddings` and `tools_vec`.
138/// Caller should call `fts::rebuild` afterwards to drop stale FTS rows.
139/// Returns the list of deleted tool ids.
140pub fn delete_by_source_repo(conn: &Connection, location: &str) -> anyhow::Result<Vec<String>> {
141    let ids: Vec<String> = {
142        let mut stmt = conn.prepare("SELECT id FROM tools WHERE source_repo = ?")?;
143        stmt.query_map(params![location], |row| row.get::<_, String>(0))?
144            .collect::<Result<Vec<_>, _>>()?
145    };
146    if ids.is_empty() {
147        return Ok(ids);
148    }
149    for id in &ids {
150        conn.execute("DELETE FROM tools_vec WHERE tool_id = ?", params![id])?;
151        conn.execute("DELETE FROM tool_embeddings WHERE tool_id = ?", params![id])?;
152        conn.execute("DELETE FROM tools WHERE id = ?", params![id])?;
153    }
154    Ok(ids)
155}
156
157pub fn get(conn: &Connection, id: &str) -> anyhow::Result<Option<ToolMeta>> {
158    let mut stmt = conn.prepare(
159        "SELECT id, type, name, source_repo, install_path, description, long_description,
160                category, triggers, examples, invocation, requires, enabled,
161                added_at, last_seen_at, last_used_at
162         FROM tools WHERE id = ?",
163    )?;
164    let mut rows = stmt.query(rusqlite::params![id])?;
165    let Some(row) = rows.next()? else {
166        return Ok(None);
167    };
168    let triggers: String = row.get(8)?;
169    let examples: String = row.get(9)?;
170    let requires: String = row.get(11)?;
171    let added_at: String = row.get(13)?;
172    let last_seen_at: String = row.get(14)?;
173    let last_used_at: Option<String> = row.get(15)?;
174    Ok(Some(ToolMeta {
175        id: row.get(0)?,
176        r#type: type_from_str(&row.get::<_, String>(1)?)?,
177        name: row.get(2)?,
178        source_repo: row.get(3)?,
179        install_path: row.get(4)?,
180        description: row.get(5)?,
181        long_description: row.get(6)?,
182        category: row.get(7)?,
183        triggers: serde_json::from_str(&triggers)?,
184        examples: serde_json::from_str(&examples)?,
185        invocation: row.get(10)?,
186        requires: serde_json::from_str(&requires)?,
187        enabled: row.get::<_, i64>(12)? != 0,
188        added_at: parse_ts(&added_at)?,
189        last_seen_at: parse_ts(&last_seen_at)?,
190        last_used_at: last_used_at.as_deref().map(parse_ts).transpose()?,
191    }))
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::open;
198    use chrono::Utc;
199
200    fn sample(id: &str, name: &str) -> ToolMeta {
201        let now = Utc::now();
202        ToolMeta {
203            id: id.to_string(),
204            r#type: ToolType::Skill,
205            name: name.to_string(),
206            source_repo: None,
207            install_path: Some("/tmp/x".into()),
208            description: Some("desc".into()),
209            long_description: Some("body".into()),
210            category: None,
211            triggers: vec!["a".into(), "b".into()],
212            examples: vec![],
213            invocation: None,
214            requires: vec!["dep:foo".into()],
215            enabled: true,
216            added_at: now,
217            last_seen_at: now,
218            last_used_at: None,
219        }
220    }
221
222    #[test]
223    fn upsert_then_list_roundtrips() {
224        let dir = tempfile::tempdir().unwrap();
225        let conn = open(&dir.path().join("t.sqlite")).unwrap();
226        upsert(&conn, &sample("skill:a", "a")).unwrap();
227        upsert(&conn, &sample("skill:b", "b")).unwrap();
228        let metas = list_all(&conn).unwrap();
229        assert_eq!(metas.len(), 2);
230        assert_eq!(metas[0].id, "skill:a");
231        assert_eq!(metas[1].id, "skill:b");
232        assert_eq!(metas[0].triggers, vec!["a", "b"]);
233        assert_eq!(metas[0].requires, vec!["dep:foo"]);
234    }
235
236    #[test]
237    fn upsert_updates_existing_row() {
238        let dir = tempfile::tempdir().unwrap();
239        let conn = open(&dir.path().join("t.sqlite")).unwrap();
240        let mut m = sample("skill:x", "x");
241        upsert(&conn, &m).unwrap();
242        m.description = Some("changed".into());
243        upsert(&conn, &m).unwrap();
244        let metas = list_all(&conn).unwrap();
245        assert_eq!(metas.len(), 1);
246        assert_eq!(metas[0].description.as_deref(), Some("changed"));
247    }
248
249    #[test]
250    fn delete_by_source_repo_drops_only_matching_rows() {
251        let dir = tempfile::tempdir().unwrap();
252        let conn = open(&dir.path().join("t.sqlite")).unwrap();
253        let mut a = sample("skill:a", "a");
254        a.source_repo = Some("https://github.com/owner/repo1".into());
255        let mut b = sample("skill:b", "b");
256        b.source_repo = Some("https://github.com/owner/repo1".into());
257        let c = sample("skill:c", "c"); // source_repo = None
258        upsert(&conn, &a).unwrap();
259        upsert(&conn, &b).unwrap();
260        upsert(&conn, &c).unwrap();
261        // Seed an embedding for one row to confirm cleanup. 384 dims.
262        let v = vec![0.1f32; 384];
263        crate::embeddings::upsert(&conn, "skill:a", &v).unwrap();
264
265        let deleted = delete_by_source_repo(&conn, "https://github.com/owner/repo1").unwrap();
266        assert_eq!(deleted.len(), 2);
267        assert!(deleted.contains(&"skill:a".to_string()));
268        assert!(deleted.contains(&"skill:b".to_string()));
269
270        let remaining = list_all(&conn).unwrap();
271        assert_eq!(remaining.len(), 1);
272        assert_eq!(remaining[0].id, "skill:c");
273
274        let emb_count: i64 = conn
275            .query_row(
276                "SELECT COUNT(*) FROM tool_embeddings WHERE tool_id = 'skill:a'",
277                [],
278                |r| r.get(0),
279            )
280            .unwrap();
281        assert_eq!(emb_count, 0);
282    }
283
284    #[test]
285    fn delete_by_source_repo_returns_empty_when_no_match() {
286        let dir = tempfile::tempdir().unwrap();
287        let conn = open(&dir.path().join("t.sqlite")).unwrap();
288        upsert(&conn, &sample("skill:a", "a")).unwrap();
289        let deleted = delete_by_source_repo(&conn, "https://github.com/none/none").unwrap();
290        assert!(deleted.is_empty());
291        assert_eq!(list_all(&conn).unwrap().len(), 1);
292    }
293}