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
136pub 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"); upsert(&conn, &a).unwrap();
259 upsert(&conn, &b).unwrap();
260 upsert(&conn, &c).unwrap();
261 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}