1use 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
44pub(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
55fn 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
106pub(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
128fn 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}