winbrew_app\operations/
list.rs

1use 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}