winbrew_app\operations/
list.rs1use anyhow::Result;
2
3use crate::database;
4use crate::models::domains::installed::InstalledPackage;
5use crate::models::domains::package::PackageQuery;
6
7pub fn list_packages(query: Option<&str>) -> Result<Vec<InstalledPackage>> {
8 let conn = database::get_conn()?;
9 let packages = database::list_packages(&conn)?;
10
11 Ok(match query {
12 Some(query) if !query.trim().is_empty() => {
13 let query = PackageQuery {
14 terms: query.split_whitespace().map(ToOwned::to_owned).collect(),
15 version: None,
16 };
17 filter_packages(packages, &query.text())
18 }
19 _ => packages,
20 })
21}
22
23fn filter_packages(packages: Vec<InstalledPackage>, query: &str) -> Vec<InstalledPackage> {
24 let normalized_query = normalize(query);
25
26 packages
27 .into_iter()
28 .filter(|pkg| package_matches(pkg, &normalized_query))
29 .collect()
30}
31
32fn package_matches(pkg: &InstalledPackage, query: &str) -> bool {
33 let haystack = [
34 pkg.name.as_str(),
35 pkg.version.as_str(),
36 pkg.kind.as_str(),
37 pkg.deployment_kind.as_str(),
38 pkg.install_dir.as_str(),
39 ]
40 .into_iter()
41 .map(normalize)
42 .collect::<Vec<_>>()
43 .join(" ");
44
45 query.split_whitespace().all(|term| haystack.contains(term))
46}
47
48fn normalize(input: &str) -> String {
49 input
50 .chars()
51 .map(|ch| {
52 if ch.is_ascii_alphanumeric() || ch.is_whitespace() {
53 ch.to_ascii_lowercase()
54 } else {
55 ' '
56 }
57 })
58 .collect::<String>()
59 .split_whitespace()
60 .collect::<Vec<_>>()
61 .join(" ")
62}
63
64#[cfg(test)]
65mod tests {
66 use super::{filter_packages, normalize};
67 use crate::models::domains::install::InstallerType;
68 use crate::models::domains::installed::{InstalledPackage, PackageStatus};
69
70 fn package(
71 name: &str,
72 version: &str,
73 kind: InstallerType,
74 install_dir: &str,
75 ) -> InstalledPackage {
76 InstalledPackage {
77 name: name.to_string(),
78 version: version.to_string(),
79 kind,
80 deployment_kind: kind.deployment_kind(),
81 engine_kind: kind.into(),
82 engine_metadata: None,
83 install_dir: install_dir.to_string(),
84 dependencies: Vec::new(),
85 status: PackageStatus::Ok,
86 installed_at: "2026-04-05T00:00:00Z".to_string(),
87 }
88 }
89
90 #[test]
91 fn normalize_collapses_punctuation_and_whitespace() {
92 assert_eq!(
93 normalize("Contoso.App v1.0\t(Stable)"),
94 "contoso app v1 0 stable"
95 );
96 }
97
98 #[test]
99 fn filter_packages_matches_terms_across_display_fields() {
100 let packages = vec![
101 package(
102 "Contoso App",
103 "1.2.3",
104 InstallerType::Msix,
105 r"C:\Packages\Contoso.App",
106 ),
107 package(
108 "Other Tool",
109 "2.0.0",
110 InstallerType::Portable,
111 r"C:\Tools\Other",
112 ),
113 ];
114
115 let matches = filter_packages(packages, "contoso 1.2 msix");
116
117 assert_eq!(matches.len(), 1);
118 assert_eq!(matches[0].name, "Contoso App");
119 }
120
121 #[test]
122 fn filter_packages_matches_deployment_kind_terms() {
123 let packages = vec![package(
124 "Contoso Archive",
125 "1.0.0",
126 InstallerType::Zip,
127 r"C:\Packages\Contoso.Archive",
128 )];
129
130 let matches = filter_packages(packages, "portable");
131
132 assert_eq!(matches.len(), 1);
133 assert_eq!(matches[0].name, "Contoso Archive");
134 }
135}