winbrew_windows\registry/
mod.rs

1use anyhow::Result;
2use regex::RegexBuilder;
3
4mod product_options;
5mod uninstall;
6
7#[cfg(any(test, feature = "testing"))]
8mod test_support;
9
10pub(crate) use product_options::read_product_type;
11use uninstall::uninstall_roots;
12
13#[cfg(any(test, feature = "testing"))]
14pub use test_support::{
15    UninstallEntryGuard, create_test_uninstall_entry,
16    create_test_uninstall_entry_with_install_location,
17};
18
19/// Snapshot of one uninstall registry entry.
20#[derive(Debug, Clone, Eq, PartialEq)]
21pub struct UninstallEntry {
22    /// Application display name.
23    pub display_name: String,
24    /// Application version string, if the registry entry exposes one.
25    pub version: String,
26    /// Publisher string, if the registry entry exposes one.
27    pub publisher: String,
28    /// Install location stored in the registry, if present.
29    pub install_location: Option<String>,
30    /// Quiet uninstall command stored in the registry, if present.
31    pub quiet_uninstall_string: Option<String>,
32    /// Standard uninstall command stored in the registry, if present.
33    pub uninstall_string: Option<String>,
34}
35
36/// Display information collected from uninstall registry entries.
37#[derive(Debug, Eq, PartialEq)]
38pub struct AppInfo {
39    /// Application display name.
40    pub name: String,
41    /// Application version string, if the registry entry exposes one.
42    pub version: String,
43    /// Publisher string, if the registry entry exposes one.
44    pub publisher: String,
45}
46
47/// Collect installed applications from the available uninstall registry entries.
48///
49/// Use [`installed_apps_matching`] when you want a case-insensitive literal
50/// filter on the display name.
51///
52/// Results are sorted by name first and then by version in descending
53/// lexicographic order. After sorting, entries with the same name are removed so
54/// the first entry for each name wins. That keeps the highest version encountered
55/// for each application name, which is good enough for display and removal
56/// workflows, but it is not a semantic-version comparison.
57pub fn installed_apps() -> Result<Vec<AppInfo>> {
58    installed_apps_with_filter(None)
59}
60
61/// Collect installed applications whose display name matches the filter.
62///
63/// The filter is treated as a case-insensitive literal search. Any regex
64/// metacharacters are escaped before matching, so the caller can pass a
65/// human-friendly package name instead of a regex.
66///
67/// # Example
68///
69/// ```no_run
70/// use winbrew_windows::installed::installed_apps_matching;
71///
72/// let apps = installed_apps_matching("winbrew").unwrap();
73/// for app in apps {
74///     println!("{} {} - {}", app.name, app.version, app.publisher);
75/// }
76/// ```
77pub fn installed_apps_matching(filter: &str) -> Result<Vec<AppInfo>> {
78    installed_apps_with_filter(Some(filter))
79}
80
81#[deprecated(note = "use installed_apps() or installed_apps_matching()")]
82#[doc(hidden)]
83pub fn collect_installed_apps(filter: Option<&str>) -> Result<Vec<AppInfo>> {
84    match filter {
85        Some(filter) => installed_apps_matching(filter),
86        None => installed_apps(),
87    }
88}
89
90fn installed_apps_with_filter(filter: Option<&str>) -> Result<Vec<AppInfo>> {
91    let mut apps = Vec::new();
92
93    visit_uninstall_entries(filter, |entry| {
94        apps.push(AppInfo {
95            name: entry.display_name,
96            version: entry.version,
97            publisher: entry.publisher,
98        });
99    })?;
100
101    // 1. First sort by name, then by version (descending).
102    apps.sort_unstable_by(|a, b| a.name.cmp(&b.name).then_with(|| b.version.cmp(&a.version)));
103
104    // 2. Deduplicate by name, keeping the highest version due to the sort order.
105    apps.dedup_by(|a, b| a.name == b.name);
106
107    Ok(apps)
108}
109
110/// Collect uninstall registry entries from the available uninstall roots.
111///
112/// Use [`uninstall_entries_matching`] when you want a case-insensitive literal
113/// filter on the entry display name.
114///
115/// Missing values are normalized to `None` or empty strings so callers can work
116/// with plain Rust types instead of registry handles.
117pub fn uninstall_entries() -> Result<Vec<UninstallEntry>> {
118    uninstall_entries_with_filter(None)
119}
120
121/// Collect uninstall registry entries that match the display-name filter.
122///
123/// The filter is treated as a case-insensitive literal search on the entry
124/// display name.
125///
126/// # Example
127///
128/// ```no_run
129/// use winbrew_windows::installed::uninstall_entries_matching;
130///
131/// for entry in uninstall_entries_matching("winbrew").unwrap() {
132///   println!("{} {}", entry.display_name, entry.version);
133/// }
134/// ```
135pub fn uninstall_entries_matching(filter: &str) -> Result<Vec<UninstallEntry>> {
136    uninstall_entries_with_filter(Some(filter))
137}
138
139#[deprecated(note = "use uninstall_entries() or uninstall_entries_matching()")]
140#[doc(hidden)]
141pub fn collect_uninstall_entries(filter: Option<&str>) -> Result<Vec<UninstallEntry>> {
142    match filter {
143        Some(filter) => uninstall_entries_matching(filter),
144        None => uninstall_entries(),
145    }
146}
147
148fn uninstall_entries_with_filter(filter: Option<&str>) -> Result<Vec<UninstallEntry>> {
149    let mut entries = Vec::new();
150
151    visit_uninstall_entries(filter, |entry| entries.push(entry))?;
152
153    Ok(entries)
154}
155
156fn visit_uninstall_entries<F>(filter: Option<&str>, mut visit: F) -> Result<()>
157where
158    F: FnMut(UninstallEntry),
159{
160    let pattern = filter
161        .map(|f| {
162            RegexBuilder::new(&regex::escape(f))
163                .case_insensitive(true)
164                .build()
165        })
166        .transpose()?;
167
168    for root in uninstall_roots() {
169        for key_result in root.key().enum_keys() {
170            let Ok(key_name) = key_result else { continue };
171            let Ok(app_key) = root.key().open_subkey(&key_name) else {
172                continue;
173            };
174
175            let Ok(display_name) = app_key.get_value::<String, _>("DisplayName") else {
176                continue;
177            };
178
179            if pattern
180                .as_ref()
181                .is_some_and(|re| !re.is_match(&display_name))
182            {
183                continue;
184            }
185
186            visit(UninstallEntry {
187                display_name,
188                version: app_key
189                    .get_value::<String, _>("DisplayVersion")
190                    .unwrap_or_default(),
191                publisher: app_key
192                    .get_value::<String, _>("Publisher")
193                    .unwrap_or_default(),
194                install_location: read_optional_string(&app_key, "InstallLocation"),
195                quiet_uninstall_string: read_optional_string(&app_key, "QuietUninstallString"),
196                uninstall_string: read_optional_string(&app_key, "UninstallString"),
197            });
198        }
199    }
200
201    Ok(())
202}
203
204/// Read the first non-empty string value from an uninstall registry entry.
205///
206/// MSI install flows use this to read `InstallLocation` after `msiexec`
207/// completes so the engine can store the final path reported by Windows.
208pub fn read_uninstall_registry_value(key_name: &str, value_name: &str) -> Option<String> {
209    for root in uninstall_roots() {
210        let Ok(app_key) = root.key().open_subkey(key_name) else {
211            continue;
212        };
213
214        let Ok(value) = app_key.get_value::<String, _>(value_name) else {
215            continue;
216        };
217
218        if !value.trim().is_empty() {
219            return Some(value);
220        }
221    }
222
223    None
224}
225
226#[deprecated(note = "use read_uninstall_registry_value")]
227#[doc(hidden)]
228pub fn uninstall_value(key_name: &str, value_name: &str) -> Option<String> {
229    read_uninstall_registry_value(key_name, value_name)
230}
231
232fn read_optional_string(app_key: &winreg::RegKey, value_name: &str) -> Option<String> {
233    let Ok(value) = app_key.get_value::<String, _>(value_name) else {
234        return None;
235    };
236
237    let value = value.trim();
238
239    if value.is_empty() {
240        None
241    } else {
242        Some(value.to_string())
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::{installed_apps_matching, uninstall_entries_matching};
249    use crate::registry::create_test_uninstall_entry_with_install_location;
250    use std::path::PathBuf;
251    use std::time::{SystemTime, UNIX_EPOCH};
252
253    fn temp_install_dir(name: &str) -> PathBuf {
254        std::env::temp_dir().join(format!(
255            "winbrew-registry-helper-{}-{}-{name}",
256            std::process::id(),
257            SystemTime::now()
258                .duration_since(UNIX_EPOCH)
259                .expect("time should be monotonic")
260                .as_nanos()
261        ))
262    }
263
264    #[test]
265    fn collects_uninstall_entries_and_projects_them_to_apps() {
266        let package_name = "WinBrew Registry Helper";
267        let install_dir = temp_install_dir("registry-helper");
268        let _guard = create_test_uninstall_entry_with_install_location(
269            package_name,
270            Some(&install_dir),
271            Some("/quiet"),
272            Some("/uninstall"),
273        )
274        .expect("test uninstall entry should be created");
275
276        let entries = uninstall_entries_matching(package_name)
277            .expect("uninstall entries should be collected");
278
279        assert_eq!(entries.len(), 1);
280        let entry = &entries[0];
281        assert_eq!(entry.display_name, package_name);
282        assert_eq!(
283            entry.install_location.as_deref(),
284            Some(install_dir.to_string_lossy().as_ref())
285        );
286        assert_eq!(entry.quiet_uninstall_string.as_deref(), Some("/quiet"));
287        assert_eq!(entry.uninstall_string.as_deref(), Some("/uninstall"));
288
289        let apps = installed_apps_matching(package_name).expect("apps should be collected");
290        assert_eq!(apps.len(), 1);
291        assert_eq!(apps[0].name, package_name);
292    }
293}