winbrew_database\catalog/
search.rs

1use anyhow::{Context, Result};
2use rusqlite::{Connection, OptionalExtension, params};
3
4use super::row::conversion_err;
5use crate::models::catalog::package::CatalogPackage;
6
7/// Search catalog packages by a full-text query.
8///
9/// Blank or whitespace-only queries return an empty result set.
10///
11/// # Errors
12///
13/// Returns an error if SQLite query execution or row conversion fails.
14pub(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
33/// Return a single catalog package by its catalog package id.
34///
35/// # Errors
36///
37/// Returns an error if SQLite query execution or row conversion fails.
38pub(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}