1use anyhow::{Context, Result};
2use rusqlite::{Connection, OptionalExtension, params};
3
4use super::row::conversion_err;
5use crate::models::catalog::package::CatalogPackage;
6
7pub(crate) fn search(conn: &Connection, query: &str) -> Result<Vec<CatalogPackage>> {
15 let query = query.trim();
16 if query.is_empty() {
17 return Ok(Vec::new());
18 }
19
20 let mut stmt = conn.prepare(
21 "SELECT p.id, p.name, p.version, p.source, p.namespace, p.source_id, p.created_at, p.updated_at, p.description, p.homepage, p.license, p.publisher, p.locale, p.moniker, p.platform, p.commands, p.protocols, p.file_extensions, p.capabilities, p.tags, p.bin, p.env_add_path
22 FROM catalog_packages p
23 JOIN catalog_packages_fts fts ON p.rowid = fts.rowid
24 WHERE catalog_packages_fts MATCH ?1
25 ORDER BY bm25(catalog_packages_fts, 10.0, 5.0, 6.0, 1.0), p.name ASC",
26 )?;
27
28 stmt.query_map(params![query], row_to_package)?
29 .collect::<std::result::Result<Vec<_>, _>>()
30 .context("failed to read catalog package")
31}
32
33pub(crate) fn get_package_by_id(
39 conn: &Connection,
40 package_id: &str,
41) -> Result<Option<CatalogPackage>> {
42 let mut stmt = conn.prepare(
43 "SELECT id, name, version, source, namespace, source_id, created_at, updated_at, description, homepage, license, publisher, locale, moniker, platform, commands, protocols, file_extensions, capabilities, tags, bin, env_add_path
44 FROM catalog_packages
45 WHERE id = ?1",
46 )?;
47
48 stmt.query_row(params![package_id], row_to_package)
49 .optional()
50 .context("failed to read catalog package")
51}
52
53fn row_to_package(row: &rusqlite::Row) -> rusqlite::Result<CatalogPackage> {
54 let version = row
55 .get::<_, String>("version")?
56 .parse()
57 .map_err(conversion_err)?;
58 let source = row
59 .get::<_, String>("source")?
60 .parse()
61 .map_err(conversion_err)?;
62
63 let package = CatalogPackage {
64 id: row.get::<_, String>("id")?.into(),
65 name: row.get("name")?,
66 version,
67 source,
68 namespace: row.get("namespace")?,
69 source_id: row.get("source_id")?,
70 created_at: row.get("created_at")?,
71 updated_at: row.get("updated_at")?,
72 description: row.get("description")?,
73 homepage: row.get("homepage")?,
74 license: row.get("license")?,
75 publisher: row.get("publisher")?,
76 locale: row.get("locale")?,
77 moniker: row.get("moniker")?,
78 platform: row.get("platform")?,
79 commands: row.get("commands")?,
80 protocols: row.get("protocols")?,
81 file_extensions: row.get("file_extensions")?,
82 capabilities: row.get("capabilities")?,
83 tags: row.get("tags")?,
84 bin: row.get("bin")?,
85 env_add_path: row.get("env_add_path")?,
86 };
87
88 package.validate().map_err(conversion_err)?;
89
90 Ok(package)
91}
92
93#[cfg(test)]
94mod tests {
95 use super::{get_package_by_id, search};
96 use rusqlite::{Connection, params};
97
98 const CATALOG_SCHEMA: &str = include_str!("../../../../infra/parser/schema/catalog.sql");
99
100 fn open_test_db() -> Connection {
101 let conn = Connection::open_in_memory().expect("open in-memory database");
102 conn.execute_batch(CATALOG_SCHEMA)
103 .expect("catalog schema should load");
104 conn
105 }
106
107 fn insert_catalog_package(
108 conn: &Connection,
109 id: &str,
110 name: &str,
111 description: Option<&str>,
112 moniker: Option<&str>,
113 tags: Option<&str>,
114 ) {
115 conn.execute(
116 r#"
117 INSERT INTO catalog_packages (
118 id, name, version, source, namespace, source_id, description, homepage, license, publisher, locale, moniker, tags, created_at, updated_at
119 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
120 "#,
121 params![
122 id,
123 name,
124 "1.2.3",
125 "winget",
126 None::<String>,
127 id.split('/').nth(1).unwrap_or(id),
128 description,
129 None::<String>,
130 None::<String>,
131 Some("Example publisher"),
132 Some("en-US"),
133 moniker,
134 tags,
135 "2026-04-14 12:00:00",
136 "2026-04-14 12:34:56",
137 ],
138 )
139 .expect("insert catalog package");
140 }
141
142 #[test]
143 fn package_queries_read_timestamps() {
144 let conn = open_test_db();
145
146 insert_catalog_package(
147 &conn,
148 "winget/Contoso.App",
149 "Contoso App",
150 Some("Example package"),
151 None,
152 None,
153 );
154
155 let package = get_package_by_id(&conn, "winget/Contoso.App")
156 .expect("package lookup should succeed")
157 .expect("package should exist");
158 let searched = search(&conn, "Contoso").expect("catalog search should succeed");
159
160 assert_eq!(package.created_at.as_deref(), Some("2026-04-14 12:00:00"));
161 assert_eq!(package.updated_at.as_deref(), Some("2026-04-14 12:34:56"));
162 assert_eq!(searched.len(), 1);
163 assert_eq!(
164 searched[0].created_at.as_deref(),
165 Some("2026-04-14 12:00:00")
166 );
167 assert_eq!(
168 searched[0].updated_at.as_deref(),
169 Some("2026-04-14 12:34:56")
170 );
171 }
172
173 #[test]
174 fn package_updates_refresh_updated_at_automatically() {
175 let conn = open_test_db();
176
177 insert_catalog_package(
178 &conn,
179 "winget/Contoso.App",
180 "Contoso App",
181 Some("Example package"),
182 None,
183 None,
184 );
185
186 conn.execute(
187 r#"
188 UPDATE catalog_packages
189 SET description = ?1
190 WHERE id = ?2
191 "#,
192 params!["Updated package", "winget/Contoso.App"],
193 )
194 .expect("update catalog package");
195
196 let package = get_package_by_id(&conn, "winget/Contoso.App")
197 .expect("package lookup should succeed")
198 .expect("package should exist");
199
200 assert_eq!(package.description.as_deref(), Some("Updated package"));
201 let updated_at = package
202 .updated_at
203 .as_deref()
204 .expect("package should have updated_at");
205 assert!(updated_at > "2026-04-14 12:34:56");
206 assert_eq!(package.created_at.as_deref(), Some("2026-04-14 12:00:00"));
207 }
208
209 #[test]
210 fn package_queries_read_env_add_path() {
211 let conn = open_test_db();
212
213 insert_catalog_package(
214 &conn,
215 "winget/Contoso.App",
216 "Contoso App",
217 Some("Example package"),
218 None,
219 None,
220 );
221
222 conn.execute(
223 r#"
224 UPDATE catalog_packages
225 SET env_add_path = ?1
226 WHERE id = ?2
227 "#,
228 params![r#"["bin","tools"]"#, "winget/Contoso.App"],
229 )
230 .expect("update env_add_path");
231
232 let package = get_package_by_id(&conn, "winget/Contoso.App")
233 .expect("package lookup should succeed")
234 .expect("package should exist");
235
236 assert_eq!(package.env_add_path.as_deref(), Some("[\"bin\",\"tools\"]"));
237 }
238
239 #[test]
240 fn search_matches_accentless_queries_against_diacritics() {
241 let conn = open_test_db();
242
243 insert_catalog_package(
244 &conn,
245 "winget/CocCoc.Browser",
246 "Cốc Cốc",
247 Some("Vietnamese browser"),
248 None,
249 Some(r#"["browser"]"#),
250 );
251
252 let searched = search(&conn, "coc").expect("catalog search should succeed");
253
254 assert_eq!(searched.len(), 1);
255 assert_eq!(searched[0].name, "Cốc Cốc");
256 }
257
258 #[test]
259 fn search_prioritizes_name_matches_over_tag_noise() {
260 let conn = open_test_db();
261
262 insert_catalog_package(
263 &conn,
264 "winget/Google.Chrome",
265 "Google Chrome",
266 Some("Web browser"),
267 None,
268 Some(r#"["browser"]"#),
269 );
270 insert_catalog_package(
271 &conn,
272 "winget/NodeJs.ChromeNoise",
273 "NodeJS",
274 Some("JavaScript runtime"),
275 None,
276 Some(r#"["chrome", "chrome", "chrome"]"#),
277 );
278
279 let searched = search(&conn, "chrome").expect("catalog search should succeed");
280
281 assert_eq!(searched.len(), 2);
282 assert_eq!(searched[0].name, "Google Chrome");
283 assert_eq!(searched[1].name, "NodeJS");
284 }
285}