winbrew_app\catalog/
search.rs

1//! Catalog lookup and package-resolution helpers.
2//!
3//! This module turns a user-facing query or package reference into a concrete
4//! catalog package. It keeps the resolution rules close to the catalog database
5//! layer so callers can depend on a single, consistent interpretation of the
6//! catalog database.
7//!
8//! Resolution follows a strict order:
9//!
10//! - exact package ID lookups are resolved directly
11//! - a single name match is returned immediately
12//! - exact name matches win when multiple packages share the same query
13//! - ambiguous name queries delegate the final choice to the caller
14//!
15//! The goal is to keep search semantics predictable for the CLI while still
16//! allowing interactive disambiguation when multiple packages match a name.
17//!
18//! Queries are trimmed before search, must not be empty, and are capped at 256
19//! characters so obviously invalid input fails fast.
20
21use anyhow::{Result, bail};
22
23use crate::database;
24use crate::models::domains::catalog::CatalogPackage;
25use crate::models::domains::package::PackageRef;
26
27const MAX_QUERY_LENGTH: usize = 256;
28
29fn validate_query(query: &str) -> Result<&str> {
30    let query = query.trim();
31
32    if query.is_empty() {
33        bail!("query cannot be empty or whitespace-only");
34    }
35
36    let query_length = query.chars().count();
37    if query_length > MAX_QUERY_LENGTH {
38        bail!("query too long: {query_length} characters (max {MAX_QUERY_LENGTH})");
39    }
40
41    Ok(query)
42}
43
44/// Search the shared catalog connection and return catalog results for the query.
45///
46/// This is the app-facing search entry point used by callers that do not
47/// already have a catalog connection open. Higher layers can map database
48/// failures into user-facing errors if they need a narrower error type.
49pub(crate) fn search_packages(query: &str) -> Result<Vec<CatalogPackage>> {
50    let query = validate_query(query)?;
51    let conn = database::get_catalog_conn()?;
52    database::search(&conn, query)
53}
54
55/// Resolve a query into a single catalog package.
56///
57/// The function first runs a catalog search and then applies the disambiguation
58/// rules described at the module level. Exact name matches are preferred over
59/// interactive selection, and the provided chooser is only consulted when the
60/// query still maps to multiple candidates.
61fn resolve_catalog_package<FChoose>(
62    conn: &database::DbConnection,
63    query: &str,
64    mut choose_package: FChoose,
65) -> Result<CatalogPackage>
66where
67    FChoose: FnMut(&str, &[CatalogPackage]) -> Result<usize>,
68{
69    let query = validate_query(query)?;
70    let mut matches = database::search(conn, query)?;
71
72    if matches.is_empty() {
73        bail!("no catalog packages matched '{query}'");
74    }
75
76    if matches.len() == 1 {
77        debug_assert_eq!(
78            matches.len(),
79            1,
80            "single-match branch must contain exactly one package"
81        );
82        return matches.pop().ok_or_else(|| {
83            anyhow::anyhow!("internal error: expected single match but vector was empty")
84        });
85    }
86
87    if let Some(exact_index) = matches
88        .iter()
89        .position(|pkg| pkg.name.eq_ignore_ascii_case(query))
90    {
91        return Ok(matches.swap_remove(exact_index));
92    }
93
94    let selected = choose_package(query, &matches)?;
95
96    if selected >= matches.len() {
97        bail!(
98            "selected package index {selected} was out of range (0..{})",
99            matches.len()
100        );
101    }
102
103    Ok(matches.swap_remove(selected))
104}
105
106/// Resolve a package reference to a single catalog package.
107///
108/// Name-based references use the same interactive disambiguation rules as a raw
109/// query. ID-based references bypass the chooser and resolve by exact catalog
110/// ID only, which keeps package references deterministic when the caller has a
111/// unique identifier.
112pub(crate) fn resolve_catalog_package_ref<FChoose>(
113    conn: &database::DbConnection,
114    package_ref: &PackageRef,
115    choose_package: FChoose,
116) -> Result<CatalogPackage>
117where
118    FChoose: FnMut(&str, &[CatalogPackage]) -> Result<usize>,
119{
120    match package_ref {
121        PackageRef::ByName(name) => resolve_catalog_package(conn, name, choose_package),
122        PackageRef::ById(package_id) => {
123            resolve_catalog_package_by_id(conn, &package_id.catalog_id())
124        }
125    }
126}
127
128/// Resolve a package by exact catalog ID.
129///
130/// The catalog ID path never asks the caller to choose between matches because
131/// a package ID is expected to identify exactly one record.
132fn resolve_catalog_package_by_id(
133    conn: &database::DbConnection,
134    package_id: &str,
135) -> Result<CatalogPackage> {
136    database::get_package_by_id(conn, package_id)?
137        .ok_or_else(|| anyhow::anyhow!("no catalog package matched '{package_id}'"))
138}
139
140#[cfg(test)]
141mod tests {
142    use super::{MAX_QUERY_LENGTH, resolve_catalog_package_ref, search_packages};
143    use crate::database;
144    use crate::models::domains::catalog::CatalogPackage;
145    use crate::models::domains::package::{PackageName, PackageRef};
146    use anyhow::Result;
147    use std::fs;
148    use tempfile::TempDir;
149    use winbrew_testing::{append_catalog_db, init_database, seed_catalog_db, test_root};
150
151    struct TestCatalog {
152        _root: TempDir,
153    }
154
155    impl TestCatalog {
156        fn with_packages(packages: &[(&str, &str, &str)]) -> Result<Self> {
157            assert!(!packages.is_empty());
158
159            let root = test_root();
160            init_database(root.path())?;
161
162            let catalog_db_path = root.path().join("data").join("db").join("catalog.db");
163            fs::create_dir_all(
164                catalog_db_path
165                    .parent()
166                    .expect("catalog database parent directory"),
167            )?;
168
169            let (first_name, first_description, first_url) = packages[0];
170            seed_catalog_db(
171                &catalog_db_path,
172                first_name,
173                first_description,
174                first_url,
175                "sha256:11111111",
176            )?;
177
178            for (index, &(name, description, url)) in packages.iter().enumerate().skip(1) {
179                let hash = format!("sha256:{index:08x}");
180                append_catalog_db(&catalog_db_path, name, description, url, &hash)?;
181            }
182
183            Ok(Self { _root: root })
184        }
185
186        fn conn(&self) -> Result<database::DbConnection> {
187            database::get_catalog_conn()
188        }
189
190        fn resolve_by_name(&self, name: &str) -> Result<CatalogPackage> {
191            self.resolve_by_name_with_chooser(name, |_, _| {
192                panic!("chooser should not be called for an exact name match")
193            })
194        }
195
196        fn resolve_by_name_with_chooser<F>(&self, name: &str, chooser: F) -> Result<CatalogPackage>
197        where
198            F: FnMut(&str, &[CatalogPackage]) -> Result<usize>,
199        {
200            let conn = self.conn()?;
201            resolve_catalog_package_ref(
202                &conn,
203                &PackageRef::ByName(PackageName::parse(name)?),
204                chooser,
205            )
206        }
207
208        fn resolve_ref(&self, package_ref: &str) -> Result<CatalogPackage> {
209            let conn = self.conn()?;
210            let package_ref = PackageRef::parse(package_ref)?;
211
212            resolve_catalog_package_ref(&conn, &package_ref, |_, _| {
213                panic!("chooser should not be called for exact id resolution")
214            })
215        }
216
217        fn search(&self, query: &str) -> Result<Vec<CatalogPackage>> {
218            search_packages(query)
219        }
220    }
221
222    #[test]
223    fn exact_name_match_bypasses_chooser() -> Result<()> {
224        let catalog = TestCatalog::with_packages(&[
225            (
226                "Contoso",
227                "Exact match package",
228                "https://example.invalid/contoso.zip",
229            ),
230            (
231                "Contoso App",
232                "Ambiguous sibling package",
233                "https://example.invalid/contoso-app.zip",
234            ),
235        ])?;
236
237        let package = catalog.resolve_by_name("Contoso")?;
238
239        assert_eq!(package.name, "Contoso");
240        Ok(())
241    }
242
243    #[test]
244    fn case_insensitive_exact_name_match_is_preferred() -> Result<()> {
245        let catalog = TestCatalog::with_packages(&[(
246            "Contoso",
247            "Exact match package",
248            "https://example.invalid/contoso.zip",
249        )])?;
250
251        let package = catalog.resolve_by_name("contoso")?;
252
253        assert_eq!(package.name, "Contoso");
254        Ok(())
255    }
256
257    #[test]
258    fn chooser_selection_returns_requested_package() -> Result<()> {
259        let catalog = TestCatalog::with_packages(&[
260            (
261                "Alpha Tool",
262                "First ambiguous package",
263                "https://example.invalid/alpha.zip",
264            ),
265            (
266                "Beta Tool",
267                "Second ambiguous package",
268                "https://example.invalid/beta.zip",
269            ),
270        ])?;
271
272        let package = catalog.resolve_by_name_with_chooser("Tool", |query, matches| {
273            assert_eq!(query, "Tool");
274            assert_eq!(matches.len(), 2);
275            Ok(matches
276                .iter()
277                .position(|pkg| pkg.name == "Beta Tool")
278                .expect("beta package should be in the chooser list"))
279        })?;
280
281        assert_eq!(package.name, "Beta Tool");
282        Ok(())
283    }
284
285    #[test]
286    fn chooser_selection_rejects_out_of_range_index() -> Result<()> {
287        let catalog = TestCatalog::with_packages(&[
288            (
289                "Alpha Tool",
290                "First ambiguous package",
291                "https://example.invalid/alpha.zip",
292            ),
293            (
294                "Beta Tool",
295                "Second ambiguous package",
296                "https://example.invalid/beta.zip",
297            ),
298        ])?;
299
300        let err = catalog
301            .resolve_by_name_with_chooser("Tool", |_, matches| Ok(matches.len()))
302            .expect_err("out-of-range chooser index should fail");
303
304        assert!(err.to_string().contains("out of range"));
305        Ok(())
306    }
307
308    #[test]
309    fn search_packages_returns_results_from_catalog_db() -> Result<()> {
310        let catalog = TestCatalog::with_packages(&[(
311            "Contoso",
312            "Exact match package",
313            "https://example.invalid/contoso.zip",
314        )])?;
315
316        let packages = catalog.search("Contoso")?;
317
318        assert_eq!(packages.len(), 1);
319        assert_eq!(packages[0].name, "Contoso");
320        Ok(())
321    }
322
323    #[test]
324    fn whitespace_only_query_is_rejected() -> Result<()> {
325        let catalog = TestCatalog::with_packages(&[(
326            "Contoso",
327            "Exact match package",
328            "https://example.invalid/contoso.zip",
329        )])?;
330
331        let err = catalog
332            .search("   ")
333            .expect_err("blank query should be rejected");
334
335        assert!(err.to_string().contains("query cannot be empty"));
336        Ok(())
337    }
338
339    #[test]
340    fn trimmed_queries_still_search_successfully() -> Result<()> {
341        let catalog = TestCatalog::with_packages(&[(
342            "Contoso",
343            "Exact match package",
344            "https://example.invalid/contoso.zip",
345        )])?;
346
347        let packages = catalog.search("  Contoso  ")?;
348
349        assert_eq!(packages.len(), 1);
350        assert_eq!(packages[0].name, "Contoso");
351        Ok(())
352    }
353
354    #[test]
355    fn very_long_query_is_rejected() -> Result<()> {
356        let catalog = TestCatalog::with_packages(&[(
357            "Contoso",
358            "Exact match package",
359            "https://example.invalid/contoso.zip",
360        )])?;
361
362        let query = "a".repeat(MAX_QUERY_LENGTH + 1);
363        let err = catalog
364            .search(&query)
365            .expect_err("oversized query should be rejected");
366
367        assert!(err.to_string().contains("query too long"));
368        Ok(())
369    }
370
371    #[test]
372    fn id_resolution_is_deterministic() -> Result<()> {
373        let catalog = TestCatalog::with_packages(&[(
374            "Contoso",
375            "Exact match package",
376            "https://example.invalid/contoso.zip",
377        )])?;
378
379        let first = catalog.resolve_ref("@winget/Contoso")?;
380        let second = catalog.resolve_ref("@winget/Contoso")?;
381
382        assert_eq!(first.id, second.id);
383        assert_eq!(first.name, "Contoso");
384        Ok(())
385    }
386}