Skip to main content

pinget_core/
lib.rs

1mod name_normalization;
2
3use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
5use std::fmt::{Display, Formatter};
6use std::fs;
7use std::io::{Cursor, Read};
8#[cfg(windows)]
9use std::mem::{MaybeUninit, size_of};
10#[cfg(windows)]
11use std::os::windows::ffi::{OsStrExt, OsStringExt};
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex, OnceLock, RwLock};
14
15use anyhow::{Context, Result, anyhow, bail};
16use chrono::{DateTime, Duration, Utc};
17use reqwest::blocking::{Client, Response};
18use serde::{Deserialize, Serialize};
19use serde_json::{Map as JsonMap, Value as JsonValue};
20use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
21use sha2::{Digest, Sha256};
22use turso::{Builder as SqlBuilder, Connection, Row as SqlRow, Value as SqlValue, params_from_iter};
23#[cfg(windows)]
24use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree};
25#[cfg(windows)]
26use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
27#[cfg(windows)]
28use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
29#[cfg(windows)]
30use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW};
31#[cfg(windows)]
32use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
33#[cfg(windows)]
34use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
35#[cfg(windows)]
36use winreg::{RegKey, enums::*};
37use zip::ZipArchive;
38
39const DEFAULT_MARKET: &str = "US";
40const DEFAULT_MAX_RESULTS: usize = 50;
41const LIST_LOOKUP_MAX_RESULTS: usize = 500;
42const PREINDEXED_CANDIDATES: &[&str] = &["source2.msix", "source.msix"];
43const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1";
44const DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES: i64 = 15;
45const PREINDEXED_REFRESH_RETRY_MINUTES: i64 = 5;
46#[cfg(windows)]
47const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe";
48#[cfg(windows)]
49const PACKAGED_NAME: &str = "Microsoft.DesktopAppInstaller";
50const INSTALLED_STATE_UNSUPPORTED_WARNING: &str =
51    "Installed package discovery is not supported on this platform; returning no installed packages.";
52const INSTALL_UNSUPPORTED_WARNING: &str =
53    "Installing packages is not supported on this platform; no changes were made.";
54const REPAIR_UNSUPPORTED_WARNING: &str = "Repairing packages is not supported on this platform; no changes were made.";
55const REPAIR_REINSTALL_WARNING: &str =
56    "Pinget repair currently re-runs the package install flow for the selected package.";
57const UNINSTALL_UNSUPPORTED_WARNING: &str =
58    "Uninstalling packages is not supported on this platform; no changes were made.";
59#[cfg(windows)]
60const WINGET_PACKAGE_NOT_FOUND_EXIT_CODE: i32 = -1978335212;
61const SUPPORTED_ADMIN_SETTINGS: &[&str] = &[
62    "LocalManifestFiles",
63    "BypassCertificatePinningForMicrosoftStore",
64    "InstallerHashOverride",
65    "LocalArchiveMalwareScanOverride",
66    "ProxyCommandLineOptions",
67];
68const REST_SUPPORTED_CONTRACTS: &[&str] = &[
69    "1.12.0", "1.10.0", "1.9.0", "1.7.0", "1.6.0", "1.5.0", "1.4.0", "1.1.0", "1.0.0",
70];
71
72#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
73pub enum SourceKind {
74    #[serde(rename = "PreIndexed", alias = "preIndexed")]
75    PreIndexed,
76    #[serde(rename = "Rest", alias = "rest")]
77    Rest,
78}
79
80impl Display for SourceKind {
81    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
82        match self {
83            SourceKind::PreIndexed => f.write_str("preindexed"),
84            SourceKind::Rest => f.write_str("rest"),
85        }
86    }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct SourceRecord {
91    pub name: String,
92    pub kind: SourceKind,
93    pub arg: String,
94    pub identifier: String,
95    #[serde(default = "default_source_trust_level")]
96    pub trust_level: String,
97    #[serde(default)]
98    pub explicit: bool,
99    #[serde(default)]
100    pub priority: i32,
101    #[serde(default)]
102    pub last_update: Option<DateTime<Utc>>,
103    #[serde(default)]
104    pub source_version: Option<String>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108struct SourceStore {
109    sources: Vec<SourceRecord>,
110}
111
112impl Default for SourceStore {
113    fn default() -> Self {
114        Self {
115            sources: vec![
116                SourceRecord {
117                    name: "winget".to_owned(),
118                    kind: SourceKind::PreIndexed,
119                    arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
120                    identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
121                    trust_level: "Trusted".to_owned(),
122                    explicit: false,
123                    priority: 0,
124                    last_update: None,
125                    source_version: None,
126                },
127                SourceRecord {
128                    name: "msstore".to_owned(),
129                    kind: SourceKind::Rest,
130                    arg: "https://storeedgefd.dsx.mp.microsoft.com/v9.0".to_owned(),
131                    identifier: "StoreEdgeFD".to_owned(),
132                    trust_level: "Trusted".to_owned(),
133                    explicit: false,
134                    priority: 0,
135                    last_update: None,
136                    source_version: None,
137                },
138            ],
139        }
140    }
141}
142
143/// Options for embedding the WinGet core library in another application.
144#[derive(Debug, Clone)]
145pub struct RepositoryOptions {
146    pub app_root: PathBuf,
147    pub user_agent: String,
148    pub pre_indexed_source_auto_update_interval: Option<Duration>,
149}
150
151impl RepositoryOptions {
152    /// Creates library options that keep all persistent state under the supplied root.
153    pub fn new(app_root: impl Into<PathBuf>) -> Self {
154        Self {
155            app_root: app_root.into(),
156            user_agent: DEFAULT_USER_AGENT.to_owned(),
157            pre_indexed_source_auto_update_interval: Some(Duration::minutes(DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES)),
158        }
159    }
160
161    /// Uses the default per-user app-data root that the CLI also uses.
162    pub fn for_current_user() -> Result<Self> {
163        Ok(Self::new(default_app_root()?))
164    }
165
166    /// Overrides the HTTP user-agent sent to source endpoints.
167    #[must_use]
168    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
169        self.user_agent = user_agent.into();
170        self
171    }
172
173    /// Overrides automatic freshness checks for existing pre-indexed source indexes.
174    #[must_use]
175    pub fn with_pre_indexed_source_auto_update_interval(mut self, interval: Option<Duration>) -> Self {
176        self.pre_indexed_source_auto_update_interval = interval;
177        self
178    }
179}
180
181#[derive(Debug, Clone, Default)]
182pub struct PackageQuery {
183    pub query: Option<String>,
184    pub id: Option<String>,
185    pub name: Option<String>,
186    pub moniker: Option<String>,
187    pub tag: Option<String>,
188    pub command: Option<String>,
189    pub source: Option<String>,
190    pub count: Option<usize>,
191    pub exact: bool,
192    pub version: Option<String>,
193    pub channel: Option<String>,
194    pub locale: Option<String>,
195    pub installer_type: Option<String>,
196    pub installer_architecture: Option<String>,
197    pub platform: Option<String>,
198    pub os_version: Option<String>,
199    pub install_scope: Option<String>,
200}
201
202#[derive(Debug, Clone, Default)]
203pub struct ListQuery {
204    pub query: Option<String>,
205    pub id: Option<String>,
206    pub name: Option<String>,
207    pub moniker: Option<String>,
208    pub tag: Option<String>,
209    pub command: Option<String>,
210    pub product_code: Option<String>,
211    pub version: Option<String>,
212    pub source: Option<String>,
213    pub count: Option<usize>,
214    pub exact: bool,
215    pub install_scope: Option<String>,
216    pub upgrade_only: bool,
217    pub include_unknown: bool,
218    pub include_pinned: bool,
219}
220
221#[derive(Debug, Clone, serde::Serialize)]
222#[serde(rename_all = "PascalCase")]
223pub struct SearchMatch {
224    pub source_name: String,
225    pub source_kind: SourceKind,
226    pub id: String,
227    pub name: String,
228    pub moniker: Option<String>,
229    pub version: Option<String>,
230    pub channel: Option<String>,
231    pub match_criteria: Option<String>,
232}
233
234#[derive(Debug, Clone, serde::Serialize)]
235#[serde(rename_all = "PascalCase")]
236pub struct SearchResponse {
237    pub matches: Vec<SearchMatch>,
238    pub warnings: Vec<String>,
239    pub truncated: bool,
240}
241
242#[derive(Debug, Clone, serde::Serialize)]
243#[serde(rename_all = "PascalCase")]
244pub struct ListMatch {
245    pub name: String,
246    pub id: String,
247    pub local_id: String,
248    pub installed_version: String,
249    pub available_version: Option<String>,
250    pub source_name: Option<String>,
251    pub publisher: Option<String>,
252    pub scope: Option<String>,
253    pub installer_category: Option<String>,
254    pub install_location: Option<String>,
255    pub package_family_names: Vec<String>,
256    pub product_codes: Vec<String>,
257    pub upgrade_codes: Vec<String>,
258}
259
260#[derive(Debug, Clone, serde::Serialize)]
261#[serde(rename_all = "PascalCase")]
262pub struct ListResponse {
263    pub matches: Vec<ListMatch>,
264    pub warnings: Vec<String>,
265    pub truncated: bool,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
269#[serde(rename_all = "PascalCase")]
270pub struct VersionKey {
271    pub version: String,
272    pub channel: String,
273}
274
275#[derive(Debug, Clone, serde::Serialize)]
276pub struct Manifest {
277    pub id: String,
278    pub name: String,
279    pub version: String,
280    pub channel: String,
281    pub publisher: Option<String>,
282    pub description: Option<String>,
283    pub moniker: Option<String>,
284    pub package_url: Option<String>,
285    pub publisher_url: Option<String>,
286    pub publisher_support_url: Option<String>,
287    pub license: Option<String>,
288    pub license_url: Option<String>,
289    pub privacy_url: Option<String>,
290    pub author: Option<String>,
291    pub copyright: Option<String>,
292    pub copyright_url: Option<String>,
293    pub release_notes: Option<String>,
294    pub release_notes_url: Option<String>,
295    pub tags: Vec<String>,
296    pub agreements: Vec<PackageAgreement>,
297    pub package_dependencies: Vec<String>,
298    pub documentation: Vec<Documentation>,
299    pub icons: Vec<PackageIcon>,
300    pub installers: Vec<Installer>,
301    // `RequireExplicitUpgrade: true` opts a package out of bulk
302    // `pinget upgrade` output (winget parity). Users can still upgrade by
303    // explicit `id`. Set at top-level or per-installer; treated as true
304    // when any installer asserts it.
305    pub require_explicit_upgrade: bool,
306}
307
308impl Manifest {
309    pub fn icon(&self) -> Option<&PackageIcon> {
310        select_best_icon(&self.icons)
311    }
312
313    pub fn icon_url(&self) -> Option<&str> {
314        self.icon().and_then(|icon| icon.icon_url.as_deref())
315    }
316}
317
318#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
319#[serde(rename_all = "PascalCase")]
320pub struct PackageIcon {
321    pub icon_url: Option<String>,
322    pub icon_file_type: Option<String>,
323    pub icon_resolution: Option<String>,
324    pub icon_theme: Option<String>,
325    pub icon_sha256: Option<String>,
326}
327
328impl PackageIcon {
329    fn is_empty(&self) -> bool {
330        self.icon_url.as_deref().is_none_or(|value| value.trim().is_empty())
331            && self
332                .icon_file_type
333                .as_deref()
334                .is_none_or(|value| value.trim().is_empty())
335            && self
336                .icon_resolution
337                .as_deref()
338                .is_none_or(|value| value.trim().is_empty())
339            && self.icon_theme.as_deref().is_none_or(|value| value.trim().is_empty())
340            && self.icon_sha256.as_deref().is_none_or(|value| value.trim().is_empty())
341    }
342}
343
344fn select_best_icon(icons: &[PackageIcon]) -> Option<&PackageIcon> {
345    icons
346        .iter()
347        .enumerate()
348        .filter(|(_, icon)| icon.icon_url.as_deref().is_some_and(|url| !url.trim().is_empty()))
349        .max_by(|(left_index, left), (right_index, right)| {
350            icon_theme_score(left.icon_theme.as_deref())
351                .cmp(&icon_theme_score(right.icon_theme.as_deref()))
352                .then_with(|| {
353                    icon_resolution_score(left.icon_resolution.as_deref())
354                        .cmp(&icon_resolution_score(right.icon_resolution.as_deref()))
355                })
356                .then_with(|| {
357                    icon_file_type_score(left.icon_file_type.as_deref())
358                        .cmp(&icon_file_type_score(right.icon_file_type.as_deref()))
359                })
360                .then_with(|| right_index.cmp(left_index))
361        })
362        .map(|(_, icon)| icon)
363}
364
365fn icon_theme_score(theme: Option<&str>) -> usize {
366    match theme {
367        None => 1,
368        Some(value) if value.trim().is_empty() || value.eq_ignore_ascii_case("default") => 1,
369        Some(_) => 0,
370    }
371}
372
373fn icon_file_type_score(file_type: Option<&str>) -> usize {
374    match file_type {
375        Some(value) if value.eq_ignore_ascii_case("ico") => 2,
376        Some(value) if value.eq_ignore_ascii_case("png") => 1,
377        _ => 0,
378    }
379}
380
381fn icon_resolution_score(resolution: Option<&str>) -> usize {
382    let Some(resolution) = resolution else {
383        return 0;
384    };
385    let resolution = resolution.trim();
386    let Some(separator) = resolution.find(['x', 'X']) else {
387        return 0;
388    };
389    let (width, height) = resolution.split_at(separator);
390    let height = &height[1..];
391    let (Ok(width), Ok(height)) = (width.parse::<usize>(), height.parse::<usize>()) else {
392        return 0;
393    };
394    if width == height { width } else { 0 }
395}
396
397#[derive(Debug, Clone, serde::Serialize)]
398pub struct Documentation {
399    pub label: Option<String>,
400    pub url: String,
401}
402
403#[derive(Debug, Clone, serde::Serialize)]
404pub struct PackageAgreement {
405    pub label: Option<String>,
406    pub text: Option<String>,
407    pub url: Option<String>,
408}
409
410#[derive(Debug, Clone, Default, serde::Serialize)]
411pub struct Installer {
412    pub architecture: Option<String>,
413    pub installer_type: Option<String>,
414    pub nested_installer_type: Option<String>,
415    pub nested_installer_files: Vec<NestedInstallerFile>,
416    pub url: Option<String>,
417    pub sha256: Option<String>,
418    pub product_code: Option<String>,
419    pub locale: Option<String>,
420    pub scope: Option<String>,
421    pub release_date: Option<String>,
422    pub package_family_name: Option<String>,
423    pub upgrade_code: Option<String>,
424    pub platforms: Vec<String>,
425    pub minimum_os_version: Option<String>,
426    pub switches: InstallerSwitches,
427    pub commands: Vec<String>,
428    pub package_dependencies: Vec<String>,
429    pub require_explicit_upgrade: bool,
430}
431
432#[derive(Debug, Clone, Default, serde::Serialize)]
433pub struct NestedInstallerFile {
434    /// Path to the executable within the extracted archive.
435    pub relative_file_path: String,
436    /// Optional alias winget exposes for portable invocation. Pinget records it
437    /// for parity and uses it during portable installation to create a
438    /// `Links\\<alias>.exe` symlink; `Links\\` may also be added to the user PATH.
439    pub portable_command_alias: Option<String>,
440}
441
442#[derive(Debug, Clone, Default, serde::Serialize)]
443pub struct InstallerSwitches {
444    pub silent: Option<String>,
445    pub silent_with_progress: Option<String>,
446    pub interactive: Option<String>,
447    pub custom: Option<String>,
448    pub log: Option<String>,
449    pub install_location: Option<String>,
450}
451
452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453pub enum InstallerMode {
454    Interactive,
455    SilentWithProgress,
456    Silent,
457}
458
459#[derive(Debug, Clone)]
460pub struct InstallRequest {
461    pub query: PackageQuery,
462    pub manifest_path: Option<PathBuf>,
463    pub mode: InstallerMode,
464    pub log_path: Option<PathBuf>,
465    pub custom: Option<String>,
466    pub override_args: Option<String>,
467    pub install_location: Option<String>,
468    pub skip_dependencies: bool,
469    pub dependencies_only: bool,
470    pub accept_package_agreements: bool,
471    pub force: bool,
472    pub rename: Option<String>,
473    pub uninstall_previous: bool,
474    pub ignore_security_hash: bool,
475    pub dependency_source: Option<String>,
476    pub no_upgrade: bool,
477}
478
479impl InstallRequest {
480    pub fn new(query: PackageQuery) -> Self {
481        Self {
482            query,
483            manifest_path: None,
484            mode: InstallerMode::SilentWithProgress,
485            log_path: None,
486            custom: None,
487            override_args: None,
488            install_location: None,
489            skip_dependencies: false,
490            dependencies_only: false,
491            accept_package_agreements: false,
492            force: false,
493            rename: None,
494            uninstall_previous: false,
495            ignore_security_hash: false,
496            dependency_source: None,
497            no_upgrade: false,
498        }
499    }
500}
501
502#[derive(Debug, Clone)]
503pub struct UninstallRequest {
504    pub query: PackageQuery,
505    pub manifest_path: Option<PathBuf>,
506    pub product_code: Option<String>,
507    pub mode: InstallerMode,
508    pub all_versions: bool,
509    pub force: bool,
510    pub purge: bool,
511    pub preserve: bool,
512    pub log_path: Option<PathBuf>,
513}
514
515impl UninstallRequest {
516    pub fn new(query: PackageQuery) -> Self {
517        Self {
518            query,
519            manifest_path: None,
520            product_code: None,
521            mode: InstallerMode::SilentWithProgress,
522            all_versions: false,
523            force: false,
524            purge: false,
525            preserve: false,
526            log_path: None,
527        }
528    }
529}
530
531#[derive(Debug, Clone)]
532pub struct RepairRequest {
533    pub query: PackageQuery,
534    pub manifest_path: Option<PathBuf>,
535    pub product_code: Option<String>,
536    pub mode: InstallerMode,
537    pub log_path: Option<PathBuf>,
538    pub accept_package_agreements: bool,
539    pub force: bool,
540    pub ignore_security_hash: bool,
541}
542
543impl RepairRequest {
544    pub fn new(query: PackageQuery) -> Self {
545        Self {
546            query,
547            manifest_path: None,
548            product_code: None,
549            mode: InstallerMode::SilentWithProgress,
550            log_path: None,
551            accept_package_agreements: false,
552            force: false,
553            ignore_security_hash: false,
554        }
555    }
556}
557
558#[derive(Debug, Clone, serde::Serialize)]
559pub struct ShowResult {
560    pub package: SearchMatch,
561    pub manifest: Manifest,
562    pub selected_installer: Option<Installer>,
563    pub cached_files: Vec<PathBuf>,
564    pub warnings: Vec<String>,
565    #[serde(skip_serializing)]
566    manifest_documents: JsonValue,
567}
568
569impl ShowResult {
570    pub fn structured_document(&self) -> JsonValue {
571        add_icon_fields_to_structured_document(collapse_structured_document(&self.manifest_documents), &self.manifest)
572    }
573}
574
575/// Result of a `download` operation. In addition to the resolved installer
576/// file on disk, pinget mirrors `winget download` by writing a flattened YAML
577/// manifest next to the installer so users get a self-contained snapshot of
578/// the package version they downloaded.
579#[derive(Debug, Clone, serde::Serialize)]
580pub struct DownloadOutput {
581    pub manifest: Manifest,
582    pub installer_path: PathBuf,
583    pub manifest_path: PathBuf,
584}
585
586/// Internal result of fetching only the installer file. Used by the install
587/// flow which downloads into a temp dir and intentionally skips the manifest
588/// YAML side effect.
589struct FetchedInstaller {
590    manifest: Manifest,
591    manifest_documents: JsonValue,
592    installer: Installer,
593    installer_path: PathBuf,
594}
595
596#[derive(Debug, Clone, serde::Serialize)]
597#[serde(rename_all = "PascalCase")]
598pub struct VersionsResult {
599    pub package: SearchMatch,
600    pub versions: Vec<VersionKey>,
601    pub warnings: Vec<String>,
602}
603
604#[derive(Debug, Clone, serde::Serialize)]
605#[serde(rename_all = "PascalCase")]
606pub struct CacheWarmResult {
607    pub package: SearchMatch,
608    pub cached_files: Vec<PathBuf>,
609    pub warnings: Vec<String>,
610}
611
612#[derive(Debug, Clone, serde::Serialize)]
613#[serde(rename_all = "PascalCase")]
614pub struct SourceUpdateResult {
615    pub name: String,
616    pub kind: SourceKind,
617    pub detail: String,
618}
619
620#[derive(Debug, Clone, serde::Serialize)]
621#[serde(rename_all = "PascalCase")]
622pub struct PinRecord {
623    pub package_id: String,
624    pub version: String,
625    pub source_id: String,
626    pub pin_type: PinType,
627}
628
629#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
630pub enum PinType {
631    Pinning,
632    Blocking,
633    Gating,
634}
635
636impl Display for PinType {
637    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
638        match self {
639            PinType::Pinning => f.write_str("Pinning"),
640            PinType::Blocking => f.write_str("Blocking"),
641            PinType::Gating => f.write_str("Gating"),
642        }
643    }
644}
645
646/// Result of an install/uninstall/upgrade operation
647#[derive(Debug, Clone, serde::Serialize)]
648pub struct InstallResult {
649    pub package_id: String,
650    pub version: String,
651    pub installer_path: PathBuf,
652    pub installer_type: String,
653    pub exit_code: i32,
654    pub success: bool,
655    pub no_op: bool,
656    pub warnings: Vec<String>,
657}
658
659impl InstallerSwitches {
660    fn with_fallback(&self, fallback: &Self) -> Self {
661        Self {
662            silent: self.silent.clone().or_else(|| fallback.silent.clone()),
663            silent_with_progress: self
664                .silent_with_progress
665                .clone()
666                .or_else(|| fallback.silent_with_progress.clone()),
667            interactive: self.interactive.clone().or_else(|| fallback.interactive.clone()),
668            custom: self.custom.clone().or_else(|| fallback.custom.clone()),
669            log: self.log.clone().or_else(|| fallback.log.clone()),
670            install_location: self
671                .install_location
672                .clone()
673                .or_else(|| fallback.install_location.clone()),
674        }
675    }
676}
677
678fn installed_package_discovery_supported() -> bool {
679    cfg!(windows)
680}
681
682fn package_actions_supported() -> bool {
683    cfg!(windows)
684}
685
686fn unsupported_action_result(
687    package_id: impl Into<String>,
688    version: impl Into<String>,
689    installer_type: impl Into<String>,
690    warning: &str,
691) -> InstallResult {
692    InstallResult {
693        package_id: package_id.into(),
694        version: version.into(),
695        installer_path: PathBuf::new(),
696        installer_type: installer_type.into(),
697        exit_code: 0,
698        success: true,
699        no_op: true,
700        warnings: vec![warning.to_owned()],
701    }
702}
703
704#[derive(Debug, Clone)]
705struct InstalledPackage {
706    name: String,
707    local_id: String,
708    installed_version: String,
709    publisher: Option<String>,
710    scope: Option<String>,
711    installer_category: Option<String>,
712    install_location: Option<String>,
713    package_family_names: Vec<String>,
714    product_codes: Vec<String>,
715    upgrade_codes: Vec<String>,
716    correlated: Option<SearchMatch>,
717    // True when `installed_version` was rewritten to a catalog Version via
718    // versionData.mszyml's aMiV/aMaV range. Such versions compare meaningfully
719    // against the catalog's Version; raw ARP DisplayVersions usually don't.
720    // Used by dedupe so that, when the same id has both a canonical and a raw
721    // install row, we prefer the canonical (e.g. `Microsoft.DotNet.SDK.10`
722    // 10.0.108 over the VS-installed 40.10.18029 that doesn't map).
723    installed_version_canonical: bool,
724    // True when the correlated catalog package's latest version sets
725    // `RequireExplicitUpgrade: true`. winget hides those rows from bulk
726    // `upgrade`; we mirror that. Users can still upgrade by explicit id.
727    correlated_requires_explicit_upgrade: bool,
728    // True when the correlated catalog package's latest version has no
729    // installer for an architecture the user can actually run. winget
730    // hides those from `upgrade`; we mirror that.
731    correlated_lacks_compatible_installer: bool,
732}
733
734#[derive(Debug, Clone)]
735struct LocatedMatch {
736    display: SearchMatch,
737    source_index: usize,
738    locator: MatchLocator,
739}
740
741#[derive(Debug)]
742struct SearchSourceMatches {
743    matches: Vec<LocatedMatch>,
744    truncated: bool,
745}
746
747#[derive(Debug, Clone)]
748enum MatchLocator {
749    PreIndexedV1 {
750        package_rowid: i64,
751    },
752    PreIndexedV2 {
753        package_rowid: i64,
754        package_hash: String,
755    },
756    Rest {
757        package_id: String,
758        versions: Vec<VersionKey>,
759    },
760}
761
762#[derive(Debug, Clone, Copy, PartialEq, Eq)]
763enum SearchSemantics {
764    Many,
765    Single,
766}
767
768#[derive(Debug, Clone, Serialize, Deserialize)]
769struct RestInfoCache {
770    expires_at: DateTime<Utc>,
771    value: RestInformation,
772}
773
774#[derive(Debug, Clone, Default, Serialize, Deserialize)]
775struct RestInformation {
776    #[serde(rename = "SourceIdentifier")]
777    source_identifier: String,
778    #[serde(rename = "ServerSupportedVersions", default)]
779    server_supported_versions: Vec<String>,
780    #[serde(rename = "RequiredPackageMatchFields", default)]
781    required_package_match_fields: Vec<String>,
782    #[serde(rename = "UnsupportedPackageMatchFields", default)]
783    unsupported_package_match_fields: Vec<String>,
784    #[serde(rename = "RequiredQueryParameters", default)]
785    required_query_parameters: Vec<String>,
786    #[serde(rename = "UnsupportedQueryParameters", default)]
787    unsupported_query_parameters: Vec<String>,
788}
789
790#[derive(Debug, Clone, Deserialize)]
791struct PackageVersionDataDocument {
792    #[serde(rename = "vD", default)]
793    versions: Vec<PackageVersionDataEntry>,
794}
795
796#[derive(Debug, Clone, Deserialize)]
797struct PackageVersionDataEntry {
798    #[serde(rename = "v")]
799    version: String,
800    #[serde(rename = "rP")]
801    manifest_relative_path: String,
802    #[serde(rename = "s256H")]
803    manifest_hash: String,
804    // ARP version range covered by this catalog version. WinGet writes these
805    // into versionData.mszyml so an installed ARP DisplayVersion (which may be
806    // an MSI build number with no relation to the catalog Version) can be
807    // mapped back to the catalog Version it corresponds to.
808    #[serde(rename = "aMiV", default)]
809    arp_min_version: Option<String>,
810    #[serde(rename = "aMaV", default)]
811    arp_max_version: Option<String>,
812}
813
814pub struct Repository {
815    app_root: PathBuf,
816    client: Client,
817    store: SourceStore,
818    use_system_winget_sources: bool,
819    pre_indexed_source_auto_update_interval: Option<Duration>,
820    preindexed_refresh_attempts: HashMap<String, PreindexedRefreshAttempt>,
821}
822
823#[derive(Debug, Clone)]
824struct PreindexedRefreshAttempt {
825    attempted_at: DateTime<Utc>,
826    succeeded: bool,
827}
828
829#[derive(Debug, Clone, Default)]
830struct PreindexedRefreshOutcome {
831    attempted: bool,
832    reused_recent_attempt: bool,
833    error: Option<String>,
834}
835
836struct PreindexedOpenConnection {
837    connection: Connection,
838    refresh: PreindexedRefreshOutcome,
839}
840
841#[derive(Debug)]
842struct MissingPackageVersionError {
843    package_id: String,
844    source_name: String,
845    requested_version: String,
846    requested_channel: Option<String>,
847}
848
849impl Display for MissingPackageVersionError {
850    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
851        let channel = self
852            .requested_channel
853            .as_deref()
854            .filter(|value| !value.is_empty())
855            .map(|value| format!(" with channel '{value}'"))
856            .unwrap_or_default();
857        write!(
858            f,
859            "Version '{}'{} was not found for package '{}' in source '{}'.",
860            self.requested_version, channel, self.package_id, self.source_name
861        )
862    }
863}
864
865impl std::error::Error for MissingPackageVersionError {}
866
867fn preindexed_refresh_locks() -> &'static RwLock<HashMap<String, Arc<Mutex<()>>>> {
868    static LOCKS: OnceLock<RwLock<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
869    LOCKS.get_or_init(|| RwLock::new(HashMap::new()))
870}
871
872impl Repository {
873    /// Opens the repository with the default per-user CLI storage layout.
874    pub fn open() -> Result<Self> {
875        Self::open_with_options(RepositoryOptions::for_current_user()?)
876    }
877
878    /// Opens the repository with explicit hosting options for library consumers.
879    pub fn open_with_options(options: RepositoryOptions) -> Result<Self> {
880        ensure_app_dirs(&options.app_root)?;
881        let use_system_winget_sources = uses_system_winget_source_commands(&options.app_root);
882        let store = load_store(&options.app_root)?;
883        let client = Client::builder()
884            .user_agent(&options.user_agent)
885            .build()
886            .context("failed to build HTTP client")?;
887        Ok(Self {
888            app_root: options.app_root,
889            client,
890            store,
891            use_system_winget_sources,
892            pre_indexed_source_auto_update_interval: options.pre_indexed_source_auto_update_interval,
893            preindexed_refresh_attempts: HashMap::new(),
894        })
895    }
896
897    pub fn app_root(&self) -> &Path {
898        &self.app_root
899    }
900
901    pub fn list_sources(&self) -> Vec<SourceRecord> {
902        self.store.sources.clone()
903    }
904
905    pub fn add_source(&mut self, name: &str, arg: &str, kind: SourceKind) -> Result<()> {
906        self.add_source_with_metadata(name, arg, kind, None, false, 0)
907    }
908
909    pub fn add_source_with_metadata(
910        &mut self,
911        name: &str,
912        arg: &str,
913        kind: SourceKind,
914        trust_level: Option<&str>,
915        explicit: bool,
916        priority: i32,
917    ) -> Result<()> {
918        if self.use_system_winget_sources {
919            system_winget_add_source(name, arg, kind, &normalize_source_trust_level(trust_level)?, explicit)?;
920            self.refresh_system_winget_sources()?;
921            return Ok(());
922        }
923
924        if self.store.sources.iter().any(|s| s.name.eq_ignore_ascii_case(name)) {
925            bail!("A source with name '{name}' already exists.");
926        }
927        if self.store.sources.iter().any(|s| s.arg == arg) {
928            bail!("A source with argument '{arg}' already exists.");
929        }
930        let identifier = name.to_owned();
931        self.store.sources.push(SourceRecord {
932            name: name.to_owned(),
933            kind,
934            arg: arg.to_owned(),
935            identifier,
936            trust_level: normalize_source_trust_level(trust_level)?,
937            explicit,
938            priority,
939            last_update: None,
940            source_version: None,
941        });
942        self.save_store()?;
943        Ok(())
944    }
945
946    pub fn edit_source(&mut self, name: &str, explicit: Option<bool>, trust_level: Option<&str>) -> Result<()> {
947        if self.use_system_winget_sources {
948            bail!("Editing system WinGet sources is not supported by winget source commands.");
949        }
950
951        let source = self
952            .store
953            .sources
954            .iter_mut()
955            .find(|source| source.name.eq_ignore_ascii_case(name))
956            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
957
958        if let Some(explicit) = explicit {
959            source.explicit = explicit;
960        }
961
962        if trust_level.is_some() {
963            source.trust_level = normalize_source_trust_level(trust_level)?;
964        }
965
966        self.save_store()?;
967        Ok(())
968    }
969
970    pub fn remove_source(&mut self, name: &str) -> Result<()> {
971        if self.use_system_winget_sources {
972            system_winget_remove_source(name)?;
973            self.refresh_system_winget_sources()?;
974            return Ok(());
975        }
976
977        let idx = self
978            .store
979            .sources
980            .iter()
981            .position(|s| s.name.eq_ignore_ascii_case(name))
982            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
983        let source = self.store.sources.remove(idx);
984        // Clean up cached state for this source
985        let state_dir = self.source_state_dir(&source);
986        if state_dir.exists() {
987            let _ = fs::remove_dir_all(&state_dir);
988        }
989        self.save_store()?;
990        Ok(())
991    }
992
993    pub fn reset_source(&mut self, name: &str) -> Result<()> {
994        if self.use_system_winget_sources {
995            system_winget_reset_source(name)?;
996            self.refresh_system_winget_sources()?;
997            return Ok(());
998        }
999
1000        let idx = self
1001            .store
1002            .sources
1003            .iter()
1004            .position(|source| source.name.eq_ignore_ascii_case(name))
1005            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1006
1007        let source = self.store.sources[idx].clone();
1008        let state_dir = self.source_state_dir(&source);
1009        if state_dir.exists() {
1010            let _ = fs::remove_dir_all(&state_dir);
1011        }
1012
1013        if let Some(default_source) = SourceStore::default()
1014            .sources
1015            .into_iter()
1016            .find(|source| source.name.eq_ignore_ascii_case(name))
1017        {
1018            self.store.sources[idx] = default_source;
1019        } else {
1020            self.store.sources[idx].last_update = None;
1021            self.store.sources[idx].source_version = None;
1022        }
1023
1024        self.save_store()?;
1025        Ok(())
1026    }
1027
1028    pub fn reset_sources(&mut self) -> Result<()> {
1029        if self.use_system_winget_sources {
1030            system_winget_reset_sources()?;
1031            self.refresh_system_winget_sources()?;
1032            return Ok(());
1033        }
1034
1035        // Remove cached state for all current sources
1036        for source in &self.store.sources {
1037            let state_dir = self.source_state_dir(source);
1038            if state_dir.exists() {
1039                let _ = fs::remove_dir_all(&state_dir);
1040            }
1041        }
1042        self.store = SourceStore::default();
1043        self.save_store()?;
1044        Ok(())
1045    }
1046
1047    pub fn supported_admin_settings() -> &'static [&'static str] {
1048        SUPPORTED_ADMIN_SETTINGS
1049    }
1050
1051    pub fn get_user_settings(&self) -> Result<JsonValue> {
1052        Ok(JsonValue::Object(load_json_object(&user_settings_path(
1053            &self.app_root,
1054        ))?))
1055    }
1056
1057    pub fn set_user_settings(&self, user_settings: &JsonValue, merge: bool) -> Result<JsonValue> {
1058        let update = match user_settings {
1059            JsonValue::Object(object) => object.clone(),
1060            _ => bail!("user settings must be a JSON object"),
1061        };
1062
1063        let effective = if merge {
1064            merge_json_objects(&load_json_object(&user_settings_path(&self.app_root))?, &update)
1065        } else {
1066            update
1067        };
1068        save_json_object(user_settings_path(&self.app_root), &effective)?;
1069        Ok(JsonValue::Object(effective))
1070    }
1071
1072    pub fn test_user_settings(&self, expected: &JsonValue, ignore_not_set: bool) -> Result<bool> {
1073        let current = self.get_user_settings()?;
1074        Ok(if ignore_not_set {
1075            json_contains(&current, expected)
1076        } else {
1077            current == *expected
1078        })
1079    }
1080
1081    pub fn get_admin_settings(&self) -> Result<JsonValue> {
1082        let mut settings = load_json_object(&admin_settings_path(&self.app_root))?;
1083        for name in SUPPORTED_ADMIN_SETTINGS {
1084            settings.entry((*name).to_owned()).or_insert(JsonValue::Bool(false));
1085        }
1086
1087        Ok(JsonValue::Object(settings))
1088    }
1089
1090    pub fn set_admin_setting(&self, name: &str, enabled: bool) -> Result<()> {
1091        let normalized = normalize_admin_setting_name(name)?;
1092        let mut settings = match self.get_admin_settings()? {
1093            JsonValue::Object(settings) => settings,
1094            _ => JsonMap::new(),
1095        };
1096        settings.insert(normalized.to_owned(), JsonValue::Bool(enabled));
1097        save_json_object(admin_settings_path(&self.app_root), &settings)
1098    }
1099
1100    pub fn reset_admin_setting(&self, name: Option<&str>, reset_all: bool) -> Result<()> {
1101        if !reset_all && name.is_none() {
1102            bail!("resetting admin settings requires a setting name or reset-all");
1103        }
1104
1105        let mut settings = match self.get_admin_settings()? {
1106            JsonValue::Object(settings) => settings,
1107            _ => JsonMap::new(),
1108        };
1109
1110        if reset_all {
1111            for setting_name in SUPPORTED_ADMIN_SETTINGS {
1112                settings.insert((*setting_name).to_owned(), JsonValue::Bool(false));
1113            }
1114        } else {
1115            settings.insert(
1116                normalize_admin_setting_name(name.expect("checked above"))?.to_owned(),
1117                JsonValue::Bool(false),
1118            );
1119        }
1120
1121        save_json_object(admin_settings_path(&self.app_root), &settings)
1122    }
1123
1124    pub fn ensure_settings_files(&self) -> Result<()> {
1125        let user_settings = user_settings_path(&self.app_root);
1126        if !user_settings.exists() {
1127            save_json_object(user_settings, &JsonMap::new())?;
1128        }
1129
1130        let admin_settings = admin_settings_path(&self.app_root);
1131        if !admin_settings.exists() {
1132            let mut values = JsonMap::new();
1133            for name in SUPPORTED_ADMIN_SETTINGS {
1134                values.insert((*name).to_owned(), JsonValue::Bool(false));
1135            }
1136            save_json_object(admin_settings, &values)?;
1137        }
1138
1139        Ok(())
1140    }
1141
1142    pub fn update_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
1143        if self.use_system_winget_sources {
1144            return self.update_system_winget_sources(source_name);
1145        }
1146
1147        let indexes = self.resolve_source_indexes(source_name)?;
1148        let mut results = Vec::new();
1149
1150        for index in indexes {
1151            let detail = match self.store.sources[index].kind {
1152                SourceKind::PreIndexed => self.update_preindexed(index)?,
1153                SourceKind::Rest => self.update_rest(index)?,
1154            };
1155
1156            results.push(SourceUpdateResult {
1157                name: self.store.sources[index].name.clone(),
1158                kind: self.store.sources[index].kind,
1159                detail,
1160            });
1161        }
1162
1163        self.save_store()?;
1164        Ok(results)
1165    }
1166
1167    pub fn search(&mut self, query: &PackageQuery) -> Result<SearchResponse> {
1168        let (matches, warnings, truncated) = self.search_located(query, SearchSemantics::Many)?;
1169        Ok(SearchResponse {
1170            matches: matches.into_iter().map(|item| item.display).collect(),
1171            warnings,
1172            truncated,
1173        })
1174    }
1175
1176    pub fn search_manifests(&mut self, query: &PackageQuery) -> Result<Vec<JsonValue>> {
1177        let (matches, _, _) = self.search_located(query, SearchSemantics::Many)?;
1178        let mut structured_documents = Vec::with_capacity(matches.len());
1179        for located in matches {
1180            let (_, manifest_documents, _) = self.manifest_for_match(&located, query)?;
1181            structured_documents.push(manifest_documents);
1182        }
1183
1184        Ok(collapse_structured_documents(&structured_documents))
1185    }
1186
1187    pub fn list(&mut self, query: &ListQuery) -> Result<ListResponse> {
1188        if (query.include_unknown || query.include_pinned) && !query.upgrade_only {
1189            bail!("--include-unknown and --include-pinned require --upgrade-available");
1190        }
1191        if query.source.is_some()
1192            && query.query.is_none()
1193            && query.id.is_none()
1194            && query.name.is_none()
1195            && query.moniker.is_none()
1196            && query.tag.is_none()
1197            && query.command.is_none()
1198        {
1199            bail!("list --source currently requires a query or explicit filter");
1200        }
1201
1202        let has_filter = list_query_needs_available_lookup(query);
1203        // Plain `list` should still correlate installed packages back to their
1204        // catalog identities so callers see canonical package ids/source names,
1205        // matching winget and the fixed C# Pinget behavior. `upgrade_only`
1206        // still adds the available-version specific filtering on top.
1207        let needs_available = true;
1208
1209        let mut warnings = Vec::new();
1210        if !installed_package_discovery_supported() {
1211            warnings.push(INSTALLED_STATE_UNSUPPORTED_WARNING.to_owned());
1212        }
1213        let mut installed = collect_installed_packages(query.install_scope.as_deref())?;
1214
1215        if needs_available {
1216            // Authoritative correlation via the v2 index's identity tables
1217            // (PackageFamilyName / ProductCode / UpgradeCode). This is winget's
1218            // primary path and resolves cases where display-name matching is
1219            // ambiguous (Microsoft.Teams vs Microsoft.Teams.Free) or impossible
1220            // (MSIX with `ms-resource:` placeholder names).
1221            warnings.extend(self.correlate_installed_via_index(&mut installed, query.source.as_deref())?);
1222
1223            // ARP entries that lack identity keys (no PFN/PC/UC) still match
1224            // winget's ARP correlation when their (DisplayName, Publisher),
1225            // run through the NameNormalizer, lands on a single package in
1226            // norm_names2 ∩ norm_publishers2. Covers Inno Setup-style
1227            // installers, MSIs whose ARP keys don't include ProductCode, and
1228            // any vendor that publishes a DisplayName that differs from the
1229            // catalog's PackageName but matches an AppsAndFeaturesEntries
1230            // DisplayName.
1231            warnings.extend(self.correlate_installed_by_normalized_identity(&mut installed, query.source.as_deref())?);
1232        }
1233
1234        if needs_available && has_filter {
1235            // Filtered lookup: search sources with the user's query for any
1236            // installed package not already resolved by identity.
1237            let available_query = package_query_from_list_query(query);
1238            let (matches, source_warnings, _) = self.search_located(&available_query, SearchSemantics::Many)?;
1239            warnings.extend(source_warnings);
1240            let candidates: Vec<SearchMatch> = matches.into_iter().map(|c| c.display).collect();
1241            for package in &mut installed {
1242                if package.correlated.is_some() {
1243                    continue;
1244                }
1245                package.correlated =
1246                    correlate_installed_package(package, &candidates, allow_loose_list_correlation(query));
1247            }
1248        } else if needs_available {
1249            // Unfiltered upgrade: look up each installed package by its correlation names
1250            warnings.extend(self.correlate_all_installed(&mut installed)?);
1251        }
1252
1253        if needs_available {
1254            // Enrich rows that correlated via the name-based fallback. Those
1255            // rows skipped the v2-index aMiV/aMaV pass that runs inside
1256            // `correlate_installed_via_index`, so they still carry the raw
1257            // ARP DisplayVersion (e.g. `JetBrains Rider 2025.3.0.1` showing
1258            // build `253.28294.112` instead of the marketing `2025.3.0.1`).
1259            // Also resolves MSIX placeholder names like
1260            // `ms-resource:appDisplayName` to the catalog's display name —
1261            // winget pulls these through Windows.Management.Deployment; the
1262            // catalog name is the parity-faithful substitute pinget has
1263            // available without taking a WinRT dependency.
1264            warnings.extend(self.enrich_correlated_via_index(&mut installed, query.source.as_deref())?);
1265        }
1266
1267        // For `upgrade`, collapse multiple installed entries that map to the
1268        // same catalog package id (different ARP rows for side-by-side .NET
1269        // SDKs, several runtime versions of WindowsAppRuntime, the MSIX shim
1270        // alongside an ARP install of Edge, etc.). Keep the entry with the
1271        // highest installed_version so the upgrade comparison runs against
1272        // the user's newest install — matches winget's one-row-per-id output.
1273        if query.upgrade_only {
1274            installed = dedupe_correlated_for_upgrade(installed);
1275        }
1276
1277        let mut matches = installed
1278            .into_iter()
1279            .filter(|package| {
1280                list_package_matches(package, query)
1281                    && (!query.upgrade_only || installed_package_matches_upgrade_filter(package, query))
1282            })
1283            .collect::<Vec<_>>();
1284
1285        matches.sort_by(|left, right| {
1286            list_sort_weight(left)
1287                .cmp(&list_sort_weight(right))
1288                .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
1289                .then_with(|| left.local_id.cmp(&right.local_id))
1290        });
1291
1292        let pins = if query.upgrade_only {
1293            self.list_pins(query.source.as_deref())?
1294        } else {
1295            Vec::new()
1296        };
1297        let mut list_matches = matches
1298            .into_iter()
1299            .map(list_match_from_installed)
1300            .filter(|item| !query.upgrade_only || query.include_pinned || !is_upgrade_blocked_by_pin(item, &pins))
1301            .collect::<Vec<_>>();
1302        let truncated = if let Some(limit) = query.count {
1303            let was_truncated = list_matches.len() > limit;
1304            list_matches.truncate(limit);
1305            was_truncated
1306        } else {
1307            false
1308        };
1309
1310        Ok(ListResponse {
1311            matches: list_matches,
1312            warnings,
1313            truncated,
1314        })
1315    }
1316
1317    /// For unfiltered upgrade/list, search the entire available index and correlate
1318    /// against all installed packages. Skips packages already correlated via the
1319    /// v2 identity tables.
1320    fn correlate_all_installed(&mut self, installed: &mut [InstalledPackage]) -> Result<Vec<String>> {
1321        if installed.iter().all(|p| p.correlated.is_some()) {
1322            return Ok(Vec::new());
1323        }
1324        let all_query = PackageQuery {
1325            query: None,
1326            id: None,
1327            name: None,
1328            moniker: None,
1329            tag: None,
1330            command: None,
1331            source: None,
1332            count: Some(100_000), // fetch the entire index
1333            exact: false,
1334            version: None,
1335            channel: None,
1336            locale: None,
1337            installer_type: None,
1338            installer_architecture: None,
1339            platform: None,
1340            os_version: None,
1341            install_scope: None,
1342        };
1343        let (matches, warnings, _) = self.search_located(&all_query, SearchSemantics::Many)?;
1344        let candidates: Vec<SearchMatch> = matches.into_iter().map(|c| c.display).collect();
1345
1346        for package in installed.iter_mut() {
1347            if package.correlated.is_some() {
1348                continue;
1349            }
1350            package.correlated = correlate_installed_package(package, &candidates, true);
1351        }
1352
1353        Ok(warnings)
1354    }
1355
1356    /// Correlates installed packages against the v2 pre-indexed catalog using
1357    /// PackageFamilyName / ProductCode / UpgradeCode lookups — winget's
1358    /// authoritative correlation path. Also rewrites `installed_version` to
1359    /// the catalog Version whose ARP range covers the ARP DisplayVersion, so
1360    /// packages whose ARP version is an MSI build number (e.g. .NET SDK,
1361    /// Python) compare against a meaningful catalog version. Packages without
1362    /// any of the three identifiers, or whose identifiers don't match a
1363    /// catalog entry, are left untouched for the name-based fallback.
1364    fn correlate_installed_via_index(
1365        &mut self,
1366        installed: &mut [InstalledPackage],
1367        requested_source: Option<&str>,
1368    ) -> Result<Vec<String>> {
1369        let source_indices: Vec<usize> = self
1370            .store
1371            .sources
1372            .iter()
1373            .enumerate()
1374            .filter(|(_, source)| {
1375                source.kind == SourceKind::PreIndexed
1376                    && match requested_source {
1377                        Some(name) => source.name.eq_ignore_ascii_case(name),
1378                        None => true,
1379                    }
1380            })
1381            .map(|(index, _)| index)
1382            .collect();
1383
1384        for source_index in source_indices {
1385            let resolved: Vec<(usize, V2PackageMetadata, &'static str)> = {
1386                let connection = match self.open_preindexed_connection(source_index) {
1387                    Ok(c) => c,
1388                    Err(_) => continue,
1389                };
1390                if !v2_identity_tables_present(&connection) {
1391                    continue;
1392                }
1393                let mut acc = Vec::new();
1394                for (idx, package) in installed.iter().enumerate() {
1395                    if package.correlated.is_some() {
1396                        continue;
1397                    }
1398                    let Some((rowid, by)) = lookup_identity_match_v2(&connection, package)? else {
1399                        continue;
1400                    };
1401                    let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1402                        continue;
1403                    };
1404                    acc.push((idx, meta, by));
1405                }
1406                acc
1407            };
1408
1409            let source = self.source_clone(source_index);
1410            for (idx, meta, by) in resolved {
1411                let (canonical_installed, anchored_latest) =
1412                    match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1413                        Ok((entries, _)) => (
1414                            map_arp_version_to_catalog(&entries, &installed[idx].installed_version),
1415                            latest_arp_anchored_version(&entries),
1416                        ),
1417                        Err(_) => (None, None),
1418                    };
1419                // When the installed side was rebased to a catalog Version via
1420                // aMiV/aMaV, the available side must compare on the same scale:
1421                // prefer the latest ARP-anchored Version over `latest_version`,
1422                // which can be an internal/MSI build number (e.g. WinAppRuntime
1423                // 1.8's `8000.836.2153.0`) that would manufacture a phantom
1424                // upgrade against an installed canonical like `1.8.6`.
1425                let available_version = match (&canonical_installed, anchored_latest) {
1426                    (Some(_), Some(anchored)) => anchored,
1427                    _ => meta.latest_version,
1428                };
1429                let installed_pkg = &mut installed[idx];
1430                installed_pkg.correlated = Some(SearchMatch {
1431                    source_name: source.name.clone(),
1432                    source_kind: source.kind,
1433                    id: meta.id,
1434                    name: meta.name,
1435                    moniker: meta.moniker,
1436                    version: Some(available_version),
1437                    channel: None,
1438                    match_criteria: Some(by.to_owned()),
1439                });
1440                if let Some(version) = canonical_installed {
1441                    installed_pkg.installed_version = version;
1442                    installed_pkg.installed_version_canonical = true;
1443                }
1444            }
1445        }
1446
1447        Ok(Vec::new())
1448    }
1449
1450    /// Mid-tier correlation that mirrors winget's ARP normalization path.
1451    /// For each installed package without a PFN/PC/UC identity match, the
1452    /// (DisplayName, Publisher) is run through the NameNormalizer and
1453    /// intersected against the v2 index's `norm_names2` and
1454    /// `norm_publishers2` tables. A single resulting `package_rowid`
1455    /// produces a correlation; ambiguous matches are skipped, matching
1456    /// winget's empty-Source behavior when normalization can't pick a
1457    /// winner.
1458    fn correlate_installed_by_normalized_identity(
1459        &mut self,
1460        installed: &mut [InstalledPackage],
1461        requested_source: Option<&str>,
1462    ) -> Result<Vec<String>> {
1463        let source_indices: Vec<usize> = self
1464            .store
1465            .sources
1466            .iter()
1467            .enumerate()
1468            .filter(|(_, source)| {
1469                source.kind == SourceKind::PreIndexed
1470                    && match requested_source {
1471                        Some(name) => source.name.eq_ignore_ascii_case(name),
1472                        None => true,
1473                    }
1474            })
1475            .map(|(index, _)| index)
1476            .collect();
1477
1478        for source_index in source_indices {
1479            let resolved: Vec<(usize, V2PackageMetadata)> = {
1480                let connection = match self.open_preindexed_connection(source_index) {
1481                    Ok(c) => c,
1482                    Err(_) => continue,
1483                };
1484                if !v2_normalized_identity_tables_present(&connection) {
1485                    continue;
1486                }
1487                let mut acc = Vec::new();
1488                for (idx, pkg) in installed.iter().enumerate() {
1489                    if pkg.correlated.is_some() {
1490                        continue;
1491                    }
1492                    // MSIX entries belong to the PFN-only path; skipping
1493                    // here mirrors `correlate_installed_package`'s
1494                    // hard-block on MSIX (their `ms-resource:` placeholder
1495                    // names normalize to garbage anyway).
1496                    if pkg.local_id.starts_with("MSIX\\") {
1497                        continue;
1498                    }
1499                    let Some(publisher) = pkg.publisher.as_deref() else {
1500                        continue;
1501                    };
1502                    let normalized_name = name_normalization::normalize_name(&pkg.name).name;
1503                    if normalized_name.is_empty() {
1504                        continue;
1505                    }
1506                    let normalized_publisher = name_normalization::normalize_publisher(publisher);
1507                    if normalized_publisher.is_empty() {
1508                        continue;
1509                    }
1510                    let Some(rowid) =
1511                        lookup_unique_normalized_identity(&connection, &normalized_name, &normalized_publisher)?
1512                    else {
1513                        continue;
1514                    };
1515                    let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1516                        continue;
1517                    };
1518                    acc.push((idx, meta));
1519                }
1520                acc
1521            };
1522
1523            let source = self.source_clone(source_index);
1524            for (idx, meta) in resolved {
1525                let (canonical_installed, anchored_latest) =
1526                    match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1527                        Ok((entries, _)) => (
1528                            map_arp_version_to_catalog(&entries, &installed[idx].installed_version),
1529                            latest_arp_anchored_version(&entries),
1530                        ),
1531                        Err(_) => (None, None),
1532                    };
1533                let available_version = match (&canonical_installed, anchored_latest) {
1534                    (Some(_), Some(anchored)) => anchored,
1535                    _ => meta.latest_version,
1536                };
1537                let installed_pkg = &mut installed[idx];
1538                installed_pkg.correlated = Some(SearchMatch {
1539                    source_name: source.name.clone(),
1540                    source_kind: source.kind,
1541                    id: meta.id,
1542                    name: meta.name,
1543                    moniker: meta.moniker,
1544                    version: Some(available_version),
1545                    channel: None,
1546                    match_criteria: Some("NormalizedNameAndPublisher".to_owned()),
1547                });
1548                if let Some(version) = canonical_installed {
1549                    installed_pkg.installed_version = version;
1550                    installed_pkg.installed_version_canonical = true;
1551                }
1552            }
1553        }
1554
1555        Ok(Vec::new())
1556    }
1557
1558    /// Runs after both correlation paths to enrich already-correlated rows
1559    /// with information that the identity path applies inline but that
1560    /// name-based correlation skips:
1561    ///   • The MSIX-placeholder name fix — when an MSIX entry's installed
1562    ///     name is an unresolved Windows resource string (`ms-resource:...`)
1563    ///     winget renders the catalog's `PackageName` instead. We do the
1564    ///     same so display-name comparisons match.
1565    ///   • The aMiV/aMaV version remap — `JetBrains.Rider` has no
1566    ///     ProductCode/PFN/UpgradeCode so it correlates by name; without
1567    ///     this pass its installed version stays as the ARP build number
1568    ///     `253.28294.112` instead of the catalog Version `2025.3.0.1`.
1569    fn enrich_correlated_via_index(
1570        &mut self,
1571        installed: &mut [InstalledPackage],
1572        requested_source: Option<&str>,
1573    ) -> Result<Vec<String>> {
1574        // Cheap pass first — no SQL required.
1575        for package in installed.iter_mut() {
1576            apply_msix_resource_string_name_fix(package);
1577        }
1578
1579        // Expensive pass — load versionData for rows whose installed_version
1580        // wasn't remapped earlier.
1581        let source_indices: Vec<usize> = self
1582            .store
1583            .sources
1584            .iter()
1585            .enumerate()
1586            .filter(|(_, source)| {
1587                source.kind == SourceKind::PreIndexed
1588                    && match requested_source {
1589                        Some(name) => source.name.eq_ignore_ascii_case(name),
1590                        None => true,
1591                    }
1592            })
1593            .map(|(index, _)| index)
1594            .collect();
1595
1596        for source_index in source_indices {
1597            let needs: Vec<(usize, V2PackageMetadata)> = {
1598                let connection = match self.open_preindexed_connection(source_index) {
1599                    Ok(c) => c,
1600                    Err(_) => continue,
1601                };
1602                if !v2_identity_tables_present(&connection) {
1603                    continue;
1604                }
1605                let mut acc = Vec::new();
1606                for (idx, pkg) in installed.iter().enumerate() {
1607                    let Some(candidate) = pkg.correlated.as_ref() else {
1608                        continue;
1609                    };
1610                    // Stay within the source the row was correlated to —
1611                    // looking up `JetBrains.Rider` in a different catalog
1612                    // would either miss or pull the wrong package's range.
1613                    if !candidate
1614                        .source_name
1615                        .eq_ignore_ascii_case(&self.store.sources[source_index].name)
1616                    {
1617                        continue;
1618                    }
1619                    let Some(meta) = lookup_v2_metadata_by_id(&connection, &candidate.id)? else {
1620                        continue;
1621                    };
1622                    acc.push((idx, meta));
1623                }
1624                acc
1625            };
1626
1627            let source = self.source_clone(source_index);
1628            for (idx, meta) in needs {
1629                // Load versionData once: we may need it for both the
1630                // aMiV/aMaV remap (only if installed_version isn't already
1631                // canonical) and to locate the latest manifest for the
1632                // RequireExplicitUpgrade flag.
1633                let entries = match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1634                    Ok((entries, _)) => entries,
1635                    Err(_) => continue,
1636                };
1637
1638                if !installed[idx].installed_version_canonical {
1639                    let canonical_installed = map_arp_version_to_catalog(&entries, &installed[idx].installed_version);
1640                    let anchored_latest = latest_arp_anchored_version(&entries);
1641                    if let Some(canonical) = canonical_installed {
1642                        installed[idx].installed_version = canonical;
1643                        installed[idx].installed_version_canonical = true;
1644                        if let (Some(correlated), Some(anchored)) =
1645                            (installed[idx].correlated.as_mut(), anchored_latest)
1646                        {
1647                            correlated.version = Some(anchored);
1648                        }
1649                    }
1650                }
1651
1652                // RequireExplicitUpgrade is set on the installer manifest
1653                // for the latest catalog version. winget hides these from
1654                // bulk `upgrade` (Edge, Steam, Discord and others). We
1655                // peek at the first versionData entry (latest by sort
1656                // order) and read its manifest to mirror that filtering.
1657                if let Some(latest) = entries.first() {
1658                    let bytes_result = self.get_cached_source_file(
1659                        "V2_M",
1660                        &source,
1661                        &latest.manifest_relative_path,
1662                        Some(latest.manifest_hash.as_str()),
1663                    );
1664                    if let Ok(bytes) = bytes_result
1665                        && let Ok(manifest) = parse_yaml_manifest(&bytes.bytes)
1666                    {
1667                        installed[idx].correlated_requires_explicit_upgrade = manifest.require_explicit_upgrade;
1668                        installed[idx].correlated_lacks_compatible_installer =
1669                            !manifest_has_compatible_installer(&manifest);
1670                    }
1671                }
1672            }
1673        }
1674
1675        Ok(Vec::new())
1676    }
1677
1678    pub fn search_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1679        let (located, warnings) = self.find_single_match_with_semantics(query, SearchSemantics::Many)?;
1680        let versions = self.versions_for_match(&located, query)?;
1681        Ok(VersionsResult {
1682            package: located.display,
1683            versions,
1684            warnings,
1685        })
1686    }
1687
1688    pub fn show_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1689        let (located, warnings) = self.find_single_match(query)?;
1690        let versions = self.versions_for_match(&located, query)?;
1691        Ok(VersionsResult {
1692            package: located.display,
1693            versions,
1694            warnings,
1695        })
1696    }
1697
1698    pub fn show(&mut self, query: &PackageQuery) -> Result<ShowResult> {
1699        let (located, warnings) = self.find_single_match(query)?;
1700        let (manifest, manifest_documents, cached_files) = self.manifest_for_match(&located, query)?;
1701        let selected_installer = select_installer(&manifest.installers, query);
1702
1703        Ok(ShowResult {
1704            package: located.display,
1705            manifest,
1706            selected_installer,
1707            cached_files,
1708            warnings,
1709            manifest_documents,
1710        })
1711    }
1712
1713    pub fn warm_cache(&mut self, query: &PackageQuery) -> Result<CacheWarmResult> {
1714        let (located, warnings) = self.find_single_match(query)?;
1715        let (_, _, cached_files) = self.manifest_for_match(&located, query)?;
1716
1717        Ok(CacheWarmResult {
1718            package: located.display,
1719            cached_files,
1720            warnings,
1721        })
1722    }
1723
1724    // ── Pin management ──
1725
1726    pub fn list_pins(&self, source_id: Option<&str>) -> Result<Vec<PinRecord>> {
1727        let db_path = pins_db_path(&self.app_root);
1728        if !db_path.exists() {
1729            return Ok(Vec::new());
1730        }
1731        let conn = Self::open_sqlite_connection(db_path)?;
1732        let pin_type_column = match resolve_pin_type_column(&conn)? {
1733            Some(column) => column,
1734            None => return Ok(Vec::new()),
1735        };
1736        let sql = if source_id.is_some() {
1737            format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin WHERE source_id = ?1")
1738        } else {
1739            format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin")
1740        };
1741        let params = if let Some(source_id) = source_id {
1742            vec![SqlValue::Text(source_id.to_owned())]
1743        } else {
1744            Vec::new()
1745        };
1746        query_rows(&conn, &sql, params, |row| {
1747            let pin_type_int = row_i64(row, 3)?;
1748            Ok(PinRecord {
1749                package_id: row_string(row, 0)?,
1750                version: row_string(row, 1)?,
1751                source_id: row_string(row, 2)?,
1752                pin_type: decode_pin_type(pin_type_int),
1753            })
1754        })
1755    }
1756
1757    pub fn add_pin(&self, package_id: &str, version: &str, source_id: &str, pin_type: PinType) -> Result<()> {
1758        let db_path = pins_db_path(&self.app_root);
1759        let conn = Self::open_sqlite_connection(db_path)?;
1760        execute_batch_sql(
1761            &conn,
1762            "CREATE TABLE IF NOT EXISTS pin (
1763                package_id TEXT NOT NULL,
1764                version TEXT NOT NULL DEFAULT '*',
1765                source_id TEXT NOT NULL DEFAULT '',
1766                type INTEGER NOT NULL DEFAULT 0,
1767                PRIMARY KEY (package_id, source_id)
1768            )",
1769        )?;
1770        let pin_type_column = resolve_pin_type_column(&conn)?.unwrap_or("type");
1771        let type_int: i64 = match pin_type {
1772            PinType::Pinning => 2,
1773            PinType::Blocking => 4,
1774            PinType::Gating => 3,
1775        };
1776        execute_sql(
1777            &conn,
1778            &format!(
1779                "INSERT OR REPLACE INTO pin (package_id, version, source_id, {pin_type_column}) VALUES (?1, ?2, ?3, ?4)"
1780            ),
1781            vec![
1782                SqlValue::Text(package_id.to_owned()),
1783                SqlValue::Text(version.to_owned()),
1784                SqlValue::Text(source_id.to_owned()),
1785                SqlValue::Integer(type_int),
1786            ],
1787        )?;
1788        conn.cacheflush().context("failed to flush pin database")?;
1789        Ok(())
1790    }
1791
1792    pub fn remove_pin(&self, package_id: &str, source_id: Option<&str>) -> Result<bool> {
1793        let db_path = pins_db_path(&self.app_root);
1794        if !db_path.exists() {
1795            return Ok(false);
1796        }
1797        let conn = Self::open_sqlite_connection(db_path)?;
1798        let count = if let Some(source_id) = source_id {
1799            execute_sql(
1800                &conn,
1801                "DELETE FROM pin WHERE package_id = ?1 AND source_id = ?2",
1802                vec![
1803                    SqlValue::Text(package_id.to_owned()),
1804                    SqlValue::Text(source_id.to_owned()),
1805                ],
1806            )?
1807        } else {
1808            execute_sql(
1809                &conn,
1810                "DELETE FROM pin WHERE package_id = ?1",
1811                vec![SqlValue::Text(package_id.to_owned())],
1812            )?
1813        };
1814        conn.cacheflush().context("failed to flush pin database")?;
1815        Ok(count > 0)
1816    }
1817
1818    pub fn reset_pins(&self, source_id: Option<&str>) -> Result<()> {
1819        let db_path = pins_db_path(&self.app_root);
1820        if !db_path.exists() {
1821            return Ok(());
1822        }
1823
1824        if let Some(source_id) = source_id {
1825            let conn = Self::open_sqlite_connection(db_path)?;
1826            execute_sql(
1827                &conn,
1828                "DELETE FROM pin WHERE source_id = ?1",
1829                vec![SqlValue::Text(source_id.to_owned())],
1830            )?;
1831            conn.cacheflush().context("failed to flush pin database")?;
1832        } else {
1833            fs::remove_file(&db_path)?;
1834        }
1835        Ok(())
1836    }
1837
1838    fn save_store(&self) -> Result<()> {
1839        save_store(&self.app_root, &self.store)
1840    }
1841
1842    fn source_state_dir(&self, source: &SourceRecord) -> PathBuf {
1843        source_state_dir(&self.app_root, source)
1844    }
1845
1846    // ── Install / download ──
1847
1848    pub fn download_installer(&mut self, query: &PackageQuery, download_dir: &Path) -> Result<DownloadOutput> {
1849        self.download_installer_for_request(&InstallRequest::new(query.clone()), download_dir)
1850    }
1851
1852    pub fn download_installer_for_request(
1853        &mut self,
1854        request: &InstallRequest,
1855        download_dir: &Path,
1856    ) -> Result<DownloadOutput> {
1857        let fetched = self.fetch_installer_for_request(request, download_dir)?;
1858        let manifest_yaml =
1859            build_download_manifest_yaml(&fetched.manifest, &fetched.manifest_documents, &fetched.installer)
1860                .context("failed to render manifest YAML")?;
1861        let manifest_filename = derive_download_manifest_filename(&fetched.manifest, &fetched.installer);
1862        let manifest_path = download_dir.join(&manifest_filename);
1863        fs::write(&manifest_path, manifest_yaml.as_bytes())
1864            .with_context(|| format!("failed to write manifest to {}", manifest_path.display()))?;
1865
1866        Ok(DownloadOutput {
1867            manifest: fetched.manifest,
1868            installer_path: fetched.installer_path,
1869            manifest_path,
1870        })
1871    }
1872
1873    /// Fetches the installer file without emitting the YAML manifest. Used by
1874    /// the install flow, which downloads into a temp dir and only needs the
1875    /// installer path — emitting a manifest there would litter the temp dir
1876    /// and turn a benign YAML-write failure into an installation failure.
1877    fn fetch_installer_for_request(
1878        &mut self,
1879        request: &InstallRequest,
1880        download_dir: &Path,
1881    ) -> Result<FetchedInstaller> {
1882        let (manifest, manifest_documents) = self.resolve_manifest_and_documents_for_install(request)?;
1883        let installer = select_installer(&manifest.installers, &request.query)
1884            .ok_or_else(|| anyhow!("No applicable installer found for the current system"))?;
1885        let url = installer
1886            .url
1887            .as_deref()
1888            .ok_or_else(|| anyhow!("Installer has no URL"))?;
1889
1890        fs::create_dir_all(download_dir)?;
1891        let filename = request.rename.as_deref().unwrap_or_else(|| {
1892            url.rsplit('/')
1893                .next()
1894                .unwrap_or("installer")
1895                .split('?')
1896                .next()
1897                .unwrap_or("installer")
1898        });
1899        let dest = download_dir.join(filename);
1900
1901        let response = self.client.get(url).send().context("failed to download installer")?;
1902        if !response.status().is_success() {
1903            bail!("Download failed: HTTP {}", response.status());
1904        }
1905        let bytes = response.bytes()?;
1906        fs::write(&dest, &bytes)?;
1907
1908        if let Err(error) = verify_installer_hash(installer.sha256.as_deref(), &bytes, request.ignore_security_hash) {
1909            let _ = fs::remove_file(&dest);
1910            return Err(error);
1911        }
1912
1913        Ok(FetchedInstaller {
1914            manifest,
1915            manifest_documents,
1916            installer,
1917            installer_path: dest,
1918        })
1919    }
1920
1921    pub fn install(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
1922        let mut request = InstallRequest::new(query.clone());
1923        request.mode = if silent {
1924            InstallerMode::Silent
1925        } else {
1926            InstallerMode::SilentWithProgress
1927        };
1928        self.install_request(&request)
1929    }
1930
1931    pub fn install_with_mode(&mut self, query: &PackageQuery, mode: InstallerMode) -> Result<InstallResult> {
1932        let mut request = InstallRequest::new(query.clone());
1933        request.mode = mode;
1934        self.install_request(&request)
1935    }
1936
1937    pub fn install_request(&mut self, request: &InstallRequest) -> Result<InstallResult> {
1938        let manifest = self.resolve_manifest_for_install(request)?;
1939        if !package_actions_supported() {
1940            let installer_type = select_installer(&manifest.installers, &request.query)
1941                .and_then(|installer| installer.installer_type)
1942                .unwrap_or_else(|| "install".to_owned())
1943                .to_lowercase();
1944            return Ok(unsupported_action_result(
1945                manifest.id.clone(),
1946                manifest.version.clone(),
1947                installer_type,
1948                INSTALL_UNSUPPORTED_WARNING,
1949            ));
1950        }
1951
1952        let existing_match = self.find_installed_package_for_install(request, &manifest)?;
1953        if let Some(no_op_result) = Self::create_install_no_op_result(request, &manifest, existing_match.as_ref()) {
1954            return Ok(no_op_result);
1955        }
1956
1957        Self::ensure_package_agreements_accepted(&manifest, request)?;
1958        self.install_dependencies(&manifest, request, &mut HashSet::new())?;
1959
1960        if request.dependencies_only {
1961            return Ok(InstallResult {
1962                package_id: manifest.id.clone(),
1963                version: manifest.version.clone(),
1964                installer_path: PathBuf::new(),
1965                installer_type: "dependencies".to_owned(),
1966                exit_code: 0,
1967                success: true,
1968                no_op: false,
1969                warnings: Vec::new(),
1970            });
1971        }
1972
1973        let installer = select_installer(&manifest.installers, &request.query)
1974            .ok_or_else(|| anyhow!("No applicable installer found"))?;
1975
1976        if request.uninstall_previous {
1977            let mut uninstall_request = UninstallRequest::new(PackageQuery {
1978                id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
1979                query: request.query.query.clone().or_else(|| Some(manifest.id.clone())),
1980                source: request.query.source.clone(),
1981                exact: true,
1982                version: request.query.version.clone(),
1983                ..PackageQuery::default()
1984            });
1985            uninstall_request.product_code = installer.product_code.clone();
1986            uninstall_request.mode = InstallerMode::Silent;
1987            uninstall_request.all_versions = true;
1988            uninstall_request.force = true;
1989            let _ = self.uninstall_request(&uninstall_request);
1990        }
1991
1992        let temp_dir = std::env::temp_dir().join("pinget-install");
1993        let installer_path = self.fetch_installer_for_request(request, &temp_dir)?.installer_path;
1994
1995        let installer_type = installer.installer_type.as_deref().unwrap_or("exe").to_lowercase();
1996
1997        let exit_code = dispatch_installer(&installer_path, &installer_type, request, &manifest, &installer)?;
1998
1999        Ok(InstallResult {
2000            package_id: manifest.id.clone(),
2001            version: manifest.version.clone(),
2002            installer_path,
2003            installer_type,
2004            exit_code,
2005            success: exit_code == 0,
2006            no_op: false,
2007            warnings: Vec::new(),
2008        })
2009    }
2010
2011    fn find_installed_package_for_install(
2012        &mut self,
2013        request: &InstallRequest,
2014        manifest: &Manifest,
2015    ) -> Result<Option<ListMatch>> {
2016        let installed_matches = self.list(&ListQuery {
2017            query: request.query.query.clone(),
2018            id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
2019            name: request.query.name.clone(),
2020            moniker: request.query.moniker.clone(),
2021            tag: None,
2022            command: None,
2023            product_code: None,
2024            version: None,
2025            source: request.query.source.clone(),
2026            count: Some(100),
2027            exact: request.query.exact || !manifest.id.trim().is_empty(),
2028            install_scope: request.query.install_scope.clone(),
2029            upgrade_only: false,
2030            include_unknown: false,
2031            include_pinned: false,
2032        })?;
2033
2034        Ok(installed_matches.matches.into_iter().find(|candidate| {
2035            candidate.id.eq_ignore_ascii_case(&manifest.id) || candidate.local_id.eq_ignore_ascii_case(&manifest.id)
2036        }))
2037    }
2038
2039    fn create_install_no_op_result(
2040        request: &InstallRequest,
2041        manifest: &Manifest,
2042        existing_match: Option<&ListMatch>,
2043    ) -> Option<InstallResult> {
2044        let existing_match = existing_match?;
2045
2046        if request.no_upgrade {
2047            return Some(InstallResult {
2048                package_id: manifest.id.clone(),
2049                version: existing_match.installed_version.clone(),
2050                installer_path: PathBuf::new(),
2051                installer_type: "install".to_owned(),
2052                exit_code: 0,
2053                success: true,
2054                no_op: true,
2055                warnings: vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()],
2056            });
2057        }
2058
2059        if !request.force
2060            && !existing_match.installed_version.trim().is_empty()
2061            && compare_version(&existing_match.installed_version, &manifest.version) != Ordering::Less
2062        {
2063            return Some(InstallResult {
2064                package_id: manifest.id.clone(),
2065                version: existing_match.installed_version.clone(),
2066                installer_path: PathBuf::new(),
2067                installer_type: "install".to_owned(),
2068                exit_code: 0,
2069                success: true,
2070                no_op: true,
2071                warnings: vec![
2072                    "Package is already installed and up to date; rerun with --force to reinstall.".to_owned(),
2073                ],
2074            });
2075        }
2076
2077        None
2078    }
2079
2080    pub fn repair(&mut self, request: &RepairRequest) -> Result<InstallResult> {
2081        if request.manifest_path.is_none() && !package_actions_supported() {
2082            let package_id = request
2083                .query
2084                .id
2085                .clone()
2086                .or_else(|| request.query.name.clone())
2087                .or_else(|| request.query.moniker.clone())
2088                .or_else(|| request.query.query.clone())
2089                .or_else(|| request.product_code.clone())
2090                .unwrap_or_else(|| "repair".to_owned());
2091            return Ok(unsupported_action_result(
2092                package_id,
2093                request.query.version.clone().unwrap_or_default(),
2094                "repair".to_owned(),
2095                REPAIR_UNSUPPORTED_WARNING,
2096            ));
2097        }
2098
2099        let mut warnings = vec![REPAIR_REINSTALL_WARNING.to_owned()];
2100        let installed_match = if request.manifest_path.is_some() {
2101            None
2102        } else {
2103            let installed = self.list(&Self::create_repair_list_query(request))?;
2104            warnings.extend(installed.warnings);
2105            if installed.matches.is_empty() {
2106                bail!("No installed package matched the supplied repair query.");
2107            }
2108            if installed.matches.len() > 1 {
2109                bail!("Multiple installed packages matched the supplied repair query.");
2110            }
2111            installed.matches.into_iter().next()
2112        };
2113
2114        let mut result =
2115            self.install_request(&Self::create_repair_install_request(request, installed_match.as_ref()))?;
2116        warnings.extend(result.warnings);
2117        result.warnings = warnings;
2118        Ok(result)
2119    }
2120
2121    pub fn uninstall(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
2122        let mut request = UninstallRequest::new(query.clone());
2123        request.mode = if silent {
2124            InstallerMode::Silent
2125        } else {
2126            InstallerMode::SilentWithProgress
2127        };
2128        self.uninstall_request(&request)
2129    }
2130
2131    pub fn uninstall_request(&mut self, request: &UninstallRequest) -> Result<InstallResult> {
2132        if !package_actions_supported() {
2133            let (package_id, version) = if let Some(path) = &request.manifest_path {
2134                let manifest = Self::load_manifest_from_path(path)?;
2135                (manifest.id, request.query.version.clone().unwrap_or(manifest.version))
2136            } else {
2137                (
2138                    request
2139                        .query
2140                        .id
2141                        .clone()
2142                        .or_else(|| request.query.query.clone())
2143                        .or_else(|| request.query.name.clone())
2144                        .or_else(|| request.query.moniker.clone())
2145                        .or_else(|| request.product_code.clone())
2146                        .unwrap_or_else(|| "unknown".to_owned()),
2147                    request.query.version.clone().unwrap_or_default(),
2148                )
2149            };
2150
2151            return Ok(unsupported_action_result(
2152                package_id,
2153                version,
2154                "uninstall",
2155                UNINSTALL_UNSUPPORTED_WARNING,
2156            ));
2157        }
2158
2159        let matches = self.resolve_uninstall_matches(request)?;
2160        let mut exit_code = 0;
2161
2162        for installed in &matches {
2163            exit_code = uninstall_package(installed, request)?;
2164            if exit_code != 0 && !request.all_versions {
2165                break;
2166            }
2167        }
2168
2169        let installed = matches
2170            .first()
2171            .ok_or_else(|| anyhow!("No installed package found matching the query"))?;
2172
2173        Ok(InstallResult {
2174            package_id: installed.id.clone(),
2175            version: installed.installed_version.clone(),
2176            installer_path: PathBuf::new(),
2177            installer_type: "uninstall".to_owned(),
2178            exit_code,
2179            success: exit_code == 0,
2180            no_op: false,
2181            warnings: Vec::new(),
2182        })
2183    }
2184
2185    fn resolve_manifest_for_install(&mut self, request: &InstallRequest) -> Result<Manifest> {
2186        self.resolve_manifest_and_documents_for_install(request)
2187            .map(|(manifest, _)| manifest)
2188    }
2189
2190    fn resolve_manifest_and_documents_for_install(
2191        &mut self,
2192        request: &InstallRequest,
2193    ) -> Result<(Manifest, JsonValue)> {
2194        if let Some(path) = &request.manifest_path {
2195            return Self::load_manifest_from_path_with_documents(path);
2196        }
2197
2198        let (located, _warnings) = self.find_single_match(&request.query)?;
2199        let (manifest, documents, _cached_files) = self.manifest_for_match(&located, &request.query)?;
2200        Ok((manifest, documents))
2201    }
2202
2203    fn ensure_package_agreements_accepted(manifest: &Manifest, request: &InstallRequest) -> Result<()> {
2204        if !request.accept_package_agreements && !manifest.agreements.is_empty() {
2205            bail!("Package agreements are present; rerun with --accept-package-agreements to continue.");
2206        }
2207        Ok(())
2208    }
2209
2210    fn install_dependencies(
2211        &mut self,
2212        manifest: &Manifest,
2213        request: &InstallRequest,
2214        visited: &mut HashSet<String>,
2215    ) -> Result<()> {
2216        if request.skip_dependencies {
2217            return Ok(());
2218        }
2219
2220        let dependency_ids = manifest
2221            .package_dependencies
2222            .iter()
2223            .chain(
2224                manifest
2225                    .installers
2226                    .iter()
2227                    .flat_map(|installer| installer.package_dependencies.iter()),
2228            )
2229            .filter(|value| !value.trim().is_empty())
2230            .cloned()
2231            .collect::<BTreeSet<_>>();
2232
2233        for dependency_id in dependency_ids {
2234            if !visited.insert(dependency_id.clone()) {
2235                continue;
2236            }
2237
2238            let dependency_request = Self::create_dependency_install_request(dependency_id, request);
2239            self.install_request(&dependency_request)?;
2240        }
2241
2242        Ok(())
2243    }
2244
2245    fn create_dependency_install_request(dependency_id: String, request: &InstallRequest) -> InstallRequest {
2246        let mut dependency_request = InstallRequest::new(PackageQuery {
2247            id: Some(dependency_id),
2248            source: request
2249                .dependency_source
2250                .clone()
2251                .or_else(|| request.query.source.clone()),
2252            exact: true,
2253            ..PackageQuery::default()
2254        });
2255        dependency_request.mode = match request.mode {
2256            InstallerMode::Interactive => InstallerMode::Interactive,
2257            InstallerMode::SilentWithProgress | InstallerMode::Silent => InstallerMode::Silent,
2258        };
2259        dependency_request.accept_package_agreements = request.accept_package_agreements;
2260        dependency_request.no_upgrade = true;
2261        dependency_request.ignore_security_hash = request.ignore_security_hash;
2262        dependency_request.dependency_source = request.dependency_source.clone();
2263        dependency_request
2264    }
2265
2266    fn create_repair_list_query(request: &RepairRequest) -> ListQuery {
2267        ListQuery {
2268            query: request.query.query.clone(),
2269            id: request.query.id.clone(),
2270            name: request.query.name.clone(),
2271            moniker: request.query.moniker.clone(),
2272            product_code: request.product_code.clone(),
2273            version: request.query.version.clone(),
2274            source: request.query.source.clone(),
2275            count: Some(100),
2276            exact: request.query.exact,
2277            install_scope: request.query.install_scope.clone(),
2278            ..ListQuery::default()
2279        }
2280    }
2281
2282    fn create_repair_install_request(request: &RepairRequest, installed_match: Option<&ListMatch>) -> InstallRequest {
2283        let mut query = request.query.clone();
2284        query.query = if installed_match.is_none() {
2285            request.query.query.clone()
2286        } else {
2287            None
2288        };
2289        query.id = installed_match
2290            .map(|item| item.id.clone())
2291            .or_else(|| request.query.id.clone());
2292        query.name = if installed_match.is_none() {
2293            request.query.name.clone()
2294        } else {
2295            None
2296        };
2297        query.moniker = if installed_match.is_none() {
2298            request.query.moniker.clone()
2299        } else {
2300            None
2301        };
2302        query.source = request
2303            .query
2304            .source
2305            .clone()
2306            .or_else(|| installed_match.and_then(|item| item.source_name.clone()));
2307        query.exact = true;
2308        query.version = request
2309            .query
2310            .version
2311            .clone()
2312            .or_else(|| installed_match.map(|item| item.installed_version.clone()));
2313
2314        InstallRequest {
2315            query,
2316            manifest_path: request.manifest_path.clone(),
2317            mode: request.mode,
2318            log_path: request.log_path.clone(),
2319            custom: None,
2320            override_args: None,
2321            install_location: None,
2322            skip_dependencies: false,
2323            dependencies_only: false,
2324            accept_package_agreements: request.accept_package_agreements,
2325            force: true,
2326            rename: None,
2327            uninstall_previous: false,
2328            ignore_security_hash: request.ignore_security_hash,
2329            dependency_source: None,
2330            no_upgrade: false,
2331        }
2332    }
2333
2334    fn resolve_uninstall_matches(&mut self, request: &UninstallRequest) -> Result<Vec<ListMatch>> {
2335        let mut effective_query = request.query.clone();
2336        let mut effective_product_code = request.product_code.clone();
2337
2338        if let Some(path) = &request.manifest_path {
2339            let manifest = Self::load_manifest_from_path(path)?;
2340            effective_query = PackageQuery {
2341                query: Some(manifest.id.clone()),
2342                id: Some(manifest.id.clone()),
2343                name: Some(manifest.name.clone()),
2344                exact: true,
2345                version: effective_query.version.clone().or(Some(manifest.version)),
2346                ..PackageQuery::default()
2347            };
2348            effective_product_code = effective_product_code.or_else(|| {
2349                manifest
2350                    .installers
2351                    .iter()
2352                    .find_map(|installer| installer.product_code.clone())
2353            });
2354        }
2355
2356        let list_query = ListQuery {
2357            query: effective_query.query.clone(),
2358            id: effective_query.id.clone(),
2359            name: effective_query.name.clone(),
2360            moniker: effective_query.moniker.clone(),
2361            tag: None,
2362            command: None,
2363            product_code: effective_product_code,
2364            version: effective_query.version.clone(),
2365            source: effective_query.source.clone(),
2366            count: if request.all_versions { None } else { Some(100) },
2367            exact: effective_query.exact,
2368            install_scope: effective_query.install_scope,
2369            upgrade_only: false,
2370            include_unknown: false,
2371            include_pinned: false,
2372        };
2373
2374        let list_result = self.list(&list_query)?;
2375        if list_result.matches.is_empty() {
2376            bail!("No installed package found matching the query");
2377        }
2378        if !request.all_versions && list_result.matches.len() > 1 && !request.force {
2379            bail!("Multiple installed packages matched the query; refine the query or use --all-versions.");
2380        }
2381
2382        Ok(if request.all_versions {
2383            list_result.matches
2384        } else {
2385            vec![list_result.matches[0].clone()]
2386        })
2387    }
2388
2389    fn load_manifest_from_path(manifest_path: &Path) -> Result<Manifest> {
2390        Self::load_manifest_from_path_with_documents(manifest_path).map(|(manifest, _)| manifest)
2391    }
2392
2393    fn load_manifest_from_path_with_documents(manifest_path: &Path) -> Result<(Manifest, JsonValue)> {
2394        let resolved = resolve_manifest_path(manifest_path)?;
2395        let bytes =
2396            fs::read(&resolved).with_context(|| format!("failed to read manifest from {}", resolved.display()))?;
2397        parse_yaml_manifest_bundle(&bytes)
2398    }
2399
2400    fn find_single_match(&mut self, query: &PackageQuery) -> Result<(LocatedMatch, Vec<String>)> {
2401        self.find_single_match_with_semantics(query, SearchSemantics::Single)
2402    }
2403
2404    fn find_single_match_with_semantics(
2405        &mut self,
2406        query: &PackageQuery,
2407        semantics: SearchSemantics,
2408    ) -> Result<(LocatedMatch, Vec<String>)> {
2409        let (matches, warnings, _) = self.search_located(query, semantics)?;
2410
2411        if matches.is_empty() {
2412            bail!("no package matched the supplied query");
2413        }
2414
2415        if matches.len() > 1 {
2416            let choices = matches
2417                .iter()
2418                .take(10)
2419                .map(|item| {
2420                    format!(
2421                        "{} [{}] ({})",
2422                        item.display.name, item.display.id, item.display.source_name
2423                    )
2424                })
2425                .collect::<Vec<_>>()
2426                .join(", ");
2427            bail!("multiple packages matched: {choices}");
2428        }
2429
2430        Ok((matches.into_iter().next().expect("one match"), warnings))
2431    }
2432
2433    fn search_located(
2434        &mut self,
2435        query: &PackageQuery,
2436        semantics: SearchSemantics,
2437    ) -> Result<(Vec<LocatedMatch>, Vec<String>, bool)> {
2438        let indexes = self.resolve_source_indexes(query.source.as_deref())?;
2439        let mut matches = Vec::new();
2440        let mut warnings = Vec::new();
2441        let mut truncated = false;
2442
2443        for index in indexes {
2444            match self.search_source(index, query, semantics) {
2445                Ok(mut source_matches) => {
2446                    truncated |= source_matches.truncated;
2447                    matches.append(&mut source_matches.matches);
2448                }
2449                Err(_error) => warnings.push(format!(
2450                    "Failed when searching source; results will not be included: {}",
2451                    self.store.sources[index].name
2452                )),
2453            }
2454        }
2455
2456        if semantics == SearchSemantics::Many {
2457            matches.sort_by(|left, right| {
2458                search_match_sort_score(&right.display, query).cmp(&search_match_sort_score(&left.display, query))
2459            });
2460            let limit = max_results(query);
2461            if matches.len() > limit {
2462                truncated = true;
2463                matches.truncate(limit);
2464            }
2465        }
2466
2467        Ok((matches, warnings, truncated))
2468    }
2469
2470    fn search_source(
2471        &mut self,
2472        source_index: usize,
2473        query: &PackageQuery,
2474        semantics: SearchSemantics,
2475    ) -> Result<SearchSourceMatches> {
2476        match self.store.sources[source_index].kind {
2477            SourceKind::PreIndexed => self.search_preindexed(source_index, query, semantics),
2478            SourceKind::Rest => self.search_rest(source_index, query, semantics),
2479        }
2480    }
2481
2482    fn versions_for_match(&mut self, located: &LocatedMatch, _query: &PackageQuery) -> Result<Vec<VersionKey>> {
2483        match &located.locator {
2484            MatchLocator::PreIndexedV1 { package_rowid } => {
2485                let source = self.source_clone(located.source_index);
2486                let connection = self.open_preindexed_connection(located.source_index)?;
2487                let versions = query_v1_versions(&connection, *package_rowid)?;
2488                let mut keys = versions
2489                    .into_iter()
2490                    .map(|row| VersionKey {
2491                        version: row.version,
2492                        channel: row.channel,
2493                    })
2494                    .collect::<Vec<_>>();
2495                sort_versions_desc(&mut keys);
2496                if keys.is_empty() {
2497                    bail!("no versions found for {} in {}", located.display.id, source.name);
2498                }
2499                Ok(keys)
2500            }
2501            MatchLocator::PreIndexedV2 {
2502                package_rowid,
2503                package_hash,
2504            } => {
2505                let source = self.source_clone(located.source_index);
2506                let (entries, _) = self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2507                let mut keys = entries
2508                    .into_iter()
2509                    .map(|entry| VersionKey {
2510                        version: entry.version,
2511                        channel: String::new(),
2512                    })
2513                    .collect::<Vec<_>>();
2514                sort_versions_desc(&mut keys);
2515                Ok(keys)
2516            }
2517            MatchLocator::Rest { versions, .. } => {
2518                let mut keys = versions.clone();
2519                sort_versions_desc(&mut keys);
2520                Ok(keys)
2521            }
2522        }
2523    }
2524
2525    fn manifest_for_match(
2526        &mut self,
2527        located: &LocatedMatch,
2528        query: &PackageQuery,
2529    ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2530        self.manifest_for_match_with_refresh(located, query, true)
2531    }
2532
2533    fn manifest_for_match_with_refresh(
2534        &mut self,
2535        located: &LocatedMatch,
2536        query: &PackageQuery,
2537        allow_explicit_version_refresh: bool,
2538    ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2539        match &located.locator {
2540            MatchLocator::PreIndexedV1 { package_rowid } => {
2541                let source = self.source_clone(located.source_index);
2542                let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2543                let versions = query_v1_versions(&opened.connection, *package_rowid)?;
2544                let selected = match select_v1_version(
2545                    &versions,
2546                    query.version.as_deref(),
2547                    query.channel.as_deref(),
2548                    &located.display.id,
2549                    &source.name,
2550                ) {
2551                    Ok(selected) => selected,
2552                    Err(error)
2553                        if allow_explicit_version_refresh
2554                            && query.version.is_some()
2555                            && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2556                    {
2557                        let open_refresh = opened.refresh.clone();
2558                        if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2559                            return missing_error_after_open_refresh(error, &open_refresh);
2560                        }
2561                        if self.has_recent_successful_preindexed_refresh_attempt(
2562                            &self.preindexed_refresh_lock_key(&source),
2563                        ) {
2564                            return Err(error);
2565                        }
2566                        drop(opened);
2567                        return self.refresh_preindexed_and_retry_manifest(
2568                            located.source_index,
2569                            &located.display,
2570                            query,
2571                            error,
2572                        );
2573                    }
2574                    Err(error) => return Err(error),
2575                };
2576                let relative_path = resolve_v1_relative_path(&opened.connection, selected.pathpart_id)?;
2577                let bytes =
2578                    self.get_cached_source_file("V1_M", &source, &relative_path, selected.manifest_hash.as_deref())?;
2579                let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&bytes.bytes)?;
2580                manifest.version = selected.version.clone();
2581                manifest.channel = selected.channel;
2582                Ok((manifest, manifest_documents, vec![bytes.path]))
2583            }
2584            MatchLocator::PreIndexedV2 {
2585                package_rowid,
2586                package_hash,
2587            } => {
2588                let source = self.source_clone(located.source_index);
2589                let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2590                let (entries, version_data_file) =
2591                    self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2592                let selected = match select_v2_version(
2593                    &entries,
2594                    query.version.as_deref(),
2595                    query.channel.as_deref(),
2596                    &located.display.id,
2597                    &source.name,
2598                ) {
2599                    Ok(selected) => selected,
2600                    Err(error)
2601                        if allow_explicit_version_refresh
2602                            && query.version.is_some()
2603                            && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2604                    {
2605                        let open_refresh = opened.refresh.clone();
2606                        if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2607                            return missing_error_after_open_refresh(error, &open_refresh);
2608                        }
2609                        if self.has_recent_successful_preindexed_refresh_attempt(
2610                            &self.preindexed_refresh_lock_key(&source),
2611                        ) {
2612                            return Err(error);
2613                        }
2614                        drop(opened);
2615                        return self.refresh_preindexed_and_retry_manifest(
2616                            located.source_index,
2617                            &located.display,
2618                            query,
2619                            error,
2620                        );
2621                    }
2622                    Err(error) => return Err(error),
2623                };
2624                let manifest_bytes = self.get_cached_source_file(
2625                    "V2_M",
2626                    &source,
2627                    &selected.manifest_relative_path,
2628                    Some(selected.manifest_hash.as_str()),
2629                )?;
2630                let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&manifest_bytes.bytes)?;
2631                manifest.version = selected.version;
2632                Ok((
2633                    manifest,
2634                    manifest_documents,
2635                    vec![version_data_file, manifest_bytes.path],
2636                ))
2637            }
2638            MatchLocator::Rest { package_id, versions } => {
2639                let source = self.source_clone(located.source_index);
2640                let selected = select_rest_version(
2641                    versions,
2642                    query.version.as_deref(),
2643                    query.channel.as_deref(),
2644                    &located.display.id,
2645                    &source.name,
2646                )?;
2647                let (bytes, cache_path) =
2648                    self.get_or_fetch_rest_manifest(&source, package_id, &selected.version, &selected.channel)?;
2649                let (manifest, manifest_documents) =
2650                    parse_rest_manifest(&bytes, package_id, &selected.version, &selected.channel)?;
2651                Ok((manifest, manifest_documents, vec![cache_path]))
2652            }
2653        }
2654    }
2655
2656    fn refresh_preindexed_and_retry_manifest(
2657        &mut self,
2658        source_index: usize,
2659        previous_display: &SearchMatch,
2660        query: &PackageQuery,
2661        missing_error: anyhow::Error,
2662    ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2663        self.refresh_preindexed_source(source_index, true)
2664            .with_context(|| format!("{missing_error} The source refresh also failed"))?;
2665        let refreshed = self.find_refreshed_preindexed_match(source_index, previous_display, query)?;
2666        self.manifest_for_match_with_refresh(&refreshed, query, false)
2667    }
2668
2669    fn find_refreshed_preindexed_match(
2670        &mut self,
2671        source_index: usize,
2672        previous_display: &SearchMatch,
2673        query: &PackageQuery,
2674    ) -> Result<LocatedMatch> {
2675        let source_name = self.store.sources[source_index].name.clone();
2676        let mut source_query = query.clone();
2677        source_query.source = Some(source_name.clone());
2678        let matches = self
2679            .search_preindexed(source_index, &source_query, SearchSemantics::Single)?
2680            .matches;
2681        if let Some(found) = matches
2682            .iter()
2683            .find(|candidate| candidate.display.id.eq_ignore_ascii_case(&previous_display.id))
2684        {
2685            return Ok(found.clone());
2686        }
2687        if matches.len() == 1 {
2688            return Ok(matches.into_iter().next().expect("single match"));
2689        }
2690        bail!(
2691            "Package '{}' was not found in source '{}' after refreshing the pre-indexed source.",
2692            previous_display.id,
2693            source_name
2694        )
2695    }
2696
2697    fn search_preindexed(
2698        &mut self,
2699        source_index: usize,
2700        query: &PackageQuery,
2701        semantics: SearchSemantics,
2702    ) -> Result<SearchSourceMatches> {
2703        let connection = self.open_preindexed_connection(source_index)?;
2704        let source = self.source_clone(source_index);
2705
2706        match query_v2_matches(&connection, query, semantics) {
2707            Ok((rows, truncated)) => Ok(SearchSourceMatches {
2708                truncated,
2709                matches: rows
2710                    .into_iter()
2711                    .map(|row| LocatedMatch {
2712                        display: SearchMatch {
2713                            source_name: source.name.clone(),
2714                            source_kind: source.kind,
2715                            id: row.id.clone(),
2716                            name: row.name.clone(),
2717                            moniker: row.moniker.clone(),
2718                            version: Some(row.version.clone()),
2719                            channel: None,
2720                            match_criteria: row.match_criteria.clone(),
2721                        },
2722                        source_index,
2723                        locator: MatchLocator::PreIndexedV2 {
2724                            package_rowid: row.package_rowid,
2725                            package_hash: row.package_hash,
2726                        },
2727                    })
2728                    .collect(),
2729            }),
2730            Err(error) if can_fallback_to_v1(&error) => {
2731                let (rows, truncated) = query_v1_matches(&connection, query, semantics)?;
2732                let grouped = group_v1_rows(rows);
2733
2734                Ok(SearchSourceMatches {
2735                    truncated,
2736                    matches: grouped
2737                        .into_iter()
2738                        .map(|row| LocatedMatch {
2739                            display: SearchMatch {
2740                                source_name: source.name.clone(),
2741                                source_kind: source.kind,
2742                                id: row.id.clone(),
2743                                name: row.name.clone(),
2744                                moniker: row.moniker.clone(),
2745                                version: Some(row.version.clone()),
2746                                channel: Some(row.channel.clone()),
2747                                match_criteria: row.match_criteria.clone(),
2748                            },
2749                            source_index,
2750                            locator: MatchLocator::PreIndexedV1 {
2751                                package_rowid: row.package_rowid,
2752                            },
2753                        })
2754                        .collect(),
2755                })
2756            }
2757            Err(error) => Err(error),
2758        }
2759    }
2760
2761    fn search_rest(
2762        &mut self,
2763        source_index: usize,
2764        query: &PackageQuery,
2765        semantics: SearchSemantics,
2766    ) -> Result<SearchSourceMatches> {
2767        let source = self.source_clone(source_index);
2768        let info = self.load_rest_information(source_index)?;
2769        let contract = choose_contract(&info.server_supported_versions)
2770            .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
2771
2772        let url = format!("{}/manifestSearch", source.arg.trim_end_matches('/'));
2773        let body = build_rest_search_body(query, &info, semantics)?;
2774        let response = self
2775            .client
2776            .post(url)
2777            .header("Version", contract)
2778            .json(&body)
2779            .send()
2780            .context("REST search request failed")?
2781            .error_for_status()
2782            .context("REST search request returned an error")?;
2783        let json = response
2784            .json::<JsonValue>()
2785            .context("failed to parse REST search response")?;
2786        let data = json
2787            .get("Data")
2788            .and_then(JsonValue::as_array)
2789            .cloned()
2790            .unwrap_or_default();
2791        let max_results = source_fetch_results(query, semantics);
2792
2793        let mut results = Vec::new();
2794        for item in data {
2795            let package_id = json_string(&item, "PackageIdentifier")
2796                .ok_or_else(|| anyhow!("REST search result missing PackageIdentifier"))?;
2797            let package_name =
2798                json_string(&item, "PackageName").ok_or_else(|| anyhow!("REST search result missing PackageName"))?;
2799            let mut versions = parse_rest_versions(&item)?;
2800            sort_versions_desc(&mut versions);
2801            let latest = versions
2802                .first()
2803                .cloned()
2804                .ok_or_else(|| anyhow!("REST search result had no versions"))?;
2805
2806            results.push(LocatedMatch {
2807                display: SearchMatch {
2808                    source_name: source.name.clone(),
2809                    source_kind: source.kind,
2810                    id: package_id.clone(),
2811                    name: package_name,
2812                    moniker: json_string(&item, "Moniker"),
2813                    version: Some(latest.version.clone()),
2814                    channel: if latest.channel.is_empty() {
2815                        None
2816                    } else {
2817                        Some(latest.channel.clone())
2818                    },
2819                    match_criteria: rest_match_criteria(&item, query, semantics),
2820                },
2821                source_index,
2822                locator: MatchLocator::Rest { package_id, versions },
2823            });
2824        }
2825
2826        Ok(SearchSourceMatches {
2827            truncated: results.len() >= max_results,
2828            matches: results,
2829        })
2830    }
2831
2832    fn open_preindexed_connection(&mut self, source_index: usize) -> Result<Connection> {
2833        Ok(self.open_preindexed_connection_with_refresh(source_index)?.connection)
2834    }
2835
2836    fn open_preindexed_connection_with_refresh(&mut self, source_index: usize) -> Result<PreindexedOpenConnection> {
2837        let source = self.source_clone(source_index);
2838        let index_path = preindexed_index_path(&self.app_root, &source);
2839        let mut refresh = PreindexedRefreshOutcome::default();
2840        if !index_path.exists() {
2841            let _ = self.refresh_preindexed_source(source_index, true)?;
2842            refresh.attempted = true;
2843        } else if self.is_preindexed_index_stale(&source, &index_path) {
2844            let lock_key = self.preindexed_refresh_lock_key(&source);
2845            if self.has_recent_preindexed_refresh_attempt(&lock_key) {
2846                refresh.attempted = true;
2847                refresh.reused_recent_attempt = true;
2848            } else {
2849                refresh.attempted = true;
2850                if let Err(error) = self.refresh_preindexed_source(source_index, false) {
2851                    refresh.error = Some(format!("{error:#}"));
2852                }
2853            }
2854        }
2855
2856        Ok(PreindexedOpenConnection {
2857            connection: Self::open_sqlite_connection(index_path).context("failed to open preindexed index")?,
2858            refresh,
2859        })
2860    }
2861
2862    fn is_preindexed_index_stale(&self, source: &SourceRecord, index_path: &Path) -> bool {
2863        let Some(interval) = self.pre_indexed_source_auto_update_interval else {
2864            return false;
2865        };
2866
2867        let index_modified = || {
2868            fs::metadata(index_path)
2869                .ok()
2870                .and_then(|metadata| metadata.modified().ok())
2871                .map(DateTime::<Utc>::from)
2872        };
2873
2874        let last_update = match (source.last_update, index_modified()) {
2875            (Some(source_last_update), Some(index_last_write)) => Some(source_last_update.max(index_last_write)),
2876            (Some(source_last_update), None) => Some(source_last_update),
2877            (None, Some(index_last_write)) => Some(index_last_write),
2878            (None, None) => None,
2879        };
2880
2881        match last_update {
2882            Some(last_update) => Utc::now() - last_update >= interval,
2883            None => true,
2884        }
2885    }
2886
2887    fn preindexed_refresh_lock_key(&self, source: &SourceRecord) -> String {
2888        format!("{}|{}|{}", self.app_root.display(), source.identifier, source.name)
2889    }
2890
2891    fn has_recent_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
2892        self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
2893            Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
2894        })
2895    }
2896
2897    fn has_recent_successful_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
2898        self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
2899            attempt.succeeded && Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
2900        })
2901    }
2902
2903    fn record_preindexed_refresh_attempt(&mut self, lock_key: String, succeeded: bool) {
2904        self.preindexed_refresh_attempts.insert(
2905            lock_key,
2906            PreindexedRefreshAttempt {
2907                attempted_at: Utc::now(),
2908                succeeded,
2909            },
2910        );
2911    }
2912
2913    fn preindexed_refresh_lock(lock_key: &str) -> Result<Arc<Mutex<()>>> {
2914        {
2915            let locks = preindexed_refresh_locks()
2916                .read()
2917                .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
2918            if let Some(existing) = locks.get(lock_key) {
2919                return Ok(Arc::clone(existing));
2920            }
2921        }
2922
2923        let mut locks = preindexed_refresh_locks()
2924            .write()
2925            .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
2926        Ok(Arc::clone(
2927            locks
2928                .entry(lock_key.to_owned())
2929                .or_insert_with(|| Arc::new(Mutex::new(()))),
2930        ))
2931    }
2932
2933    fn refresh_preindexed_source(&mut self, source_index: usize, force: bool) -> Result<String> {
2934        let source = self.source_clone(source_index);
2935        let index_path = preindexed_index_path(&self.app_root, &source);
2936        let lock_key = self.preindexed_refresh_lock_key(&source);
2937        let refresh_lock = Self::preindexed_refresh_lock(&lock_key)?;
2938        let _guard = refresh_lock
2939            .lock()
2940            .map_err(|_| anyhow!("pre-indexed source refresh lock was poisoned"))?;
2941
2942        if !force && index_path.exists() && !self.is_preindexed_index_stale(&source, &index_path) {
2943            return Ok("source already refreshed".to_owned());
2944        }
2945
2946        let result = self.update_preindexed(source_index);
2947        self.record_preindexed_refresh_attempt(lock_key, result.is_ok());
2948        let detail = result?;
2949        if !self.use_system_winget_sources {
2950            self.save_store()?;
2951        }
2952        Ok(detail)
2953    }
2954
2955    fn open_sqlite_connection(path: PathBuf) -> Result<Connection> {
2956        let path_text = path.to_string_lossy().into_owned();
2957        let database = pollster::block_on(SqlBuilder::new_local(&path_text).build())
2958            .with_context(|| format!("failed to open SQLite database at {path_text}"))?;
2959        database
2960            .connect()
2961            .with_context(|| format!("failed to connect to SQLite database at {path_text}"))
2962    }
2963
2964    fn update_preindexed(&mut self, source_index: usize) -> Result<String> {
2965        let source = &mut self.store.sources[source_index];
2966        let state_dir = source_state_dir(&self.app_root, source);
2967        fs::create_dir_all(&state_dir).context("failed to create source state directory")?;
2968
2969        let mut last_error = None;
2970        for candidate in PREINDEXED_CANDIDATES {
2971            let url = format!("{}/{}", source.arg.trim_end_matches('/'), candidate);
2972            match self.client.get(&url).send() {
2973                Ok(response) if response.status().is_success() => {
2974                    let header_version = response
2975                        .headers()
2976                        .get("x-ms-meta-sourceversion")
2977                        .and_then(|value| value.to_str().ok())
2978                        .map(str::to_owned);
2979                    let bytes = response.bytes().context("failed to read preindexed package bytes")?;
2980                    let payload = bytes.to_vec();
2981                    let index_bytes = extract_zip_member(&payload, "Public/index.db")
2982                        .context("preindexed package did not contain Public/index.db")?;
2983
2984                    fs::write(preindexed_package_path(&self.app_root, source), &payload)
2985                        .context("failed to persist source package")?;
2986                    let index_path = preindexed_index_path(&self.app_root, source);
2987                    let temp_index_path = state_dir.join(format!(
2988                        "index.{}.tmp",
2989                        Utc::now().timestamp_nanos_opt().unwrap_or_default()
2990                    ));
2991                    fs::write(&temp_index_path, index_bytes).context("failed to persist temporary source index")?;
2992                    if let Err(error) = replace_file(&temp_index_path, &index_path) {
2993                        let _ = fs::remove_file(&temp_index_path);
2994                        return Err(error).context("failed to persist source index");
2995                    }
2996                    // The new index.db is a fresh database; any WAL/SHM sidecars
2997                    // left from the previous one no longer match it and would make
2998                    // SQLite recover against the wrong database. Drop them so the
2999                    // next open starts clean.
3000                    remove_sqlite_sidecars(&index_path);
3001                    source.last_update = Some(Utc::now());
3002                    source.source_version = header_version;
3003                    return Ok(format!("downloaded {}", candidate));
3004                }
3005                Ok(response) => {
3006                    last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
3007                }
3008                Err(error) => {
3009                    last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
3010                }
3011            }
3012        }
3013
3014        Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
3015    }
3016
3017    fn update_rest(&mut self, source_index: usize) -> Result<String> {
3018        let info = self.fetch_rest_information(source_index, true)?;
3019        let source = &mut self.store.sources[source_index];
3020        if !info.source_identifier.is_empty() {
3021            source.identifier = info.source_identifier.clone();
3022        }
3023        source.last_update = Some(Utc::now());
3024        source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
3025        Ok("refreshed information cache".to_owned())
3026    }
3027
3028    fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
3029        let source = self.source_clone(source_index);
3030        let cache_path = rest_information_cache_path(&self.app_root, &source);
3031
3032        if cache_path.exists() {
3033            let cache = serde_json::from_slice::<RestInfoCache>(
3034                &fs::read(&cache_path).context("failed to read REST information cache")?,
3035            )
3036            .context("failed to parse REST information cache")?;
3037            if cache.expires_at > Utc::now() {
3038                return Ok(cache.value);
3039            }
3040        }
3041
3042        self.fetch_rest_information(source_index, true)
3043    }
3044
3045    fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
3046        let source = self.source_clone(source_index);
3047        let url = format!("{}/information", source.arg.trim_end_matches('/'));
3048        let response = self
3049            .client
3050            .get(url)
3051            .send()
3052            .context("REST information request failed")?
3053            .error_for_status()
3054            .context("REST information request returned an error")?;
3055
3056        let max_age = cache_control_max_age(&response);
3057        let json = response
3058            .json::<JsonValue>()
3059            .context("failed to parse REST information response")?;
3060        let data = json
3061            .get("Data")
3062            .cloned()
3063            .ok_or_else(|| anyhow!("REST information response missing Data"))?;
3064        let info = serde_json::from_value::<RestInformation>(data)
3065            .context("failed to deserialize REST information payload")?;
3066
3067        if persist {
3068            let cache = RestInfoCache {
3069                expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
3070                value: info.clone(),
3071            };
3072            write_json(rest_information_cache_path(&self.app_root, &source), &cache)?;
3073        }
3074
3075        Ok(info)
3076    }
3077
3078    fn load_v2_version_data(
3079        &self,
3080        source: &SourceRecord,
3081        package_rowid: i64,
3082        package_hash: &str,
3083    ) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
3084        let connection = Self::open_sqlite_connection(preindexed_index_path(&self.app_root, source))
3085            .context("failed to reopen preindexed index for V2 version data")?;
3086        let package_hash = package_hash.to_ascii_lowercase();
3087        let package_id = query_optional_value(
3088            &connection,
3089            "SELECT id FROM packages WHERE rowid = ?1",
3090            vec![SqlValue::Integer(package_rowid)],
3091            |row| row_string(row, 0),
3092        )?
3093        .ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
3094        let relative_path = package_version_data_relative_path(&package_id, &package_hash);
3095        let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
3096        let yaml = decompress_mszyml(&file.bytes)?;
3097        let document =
3098            serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
3099        Ok((document.versions, file.path))
3100    }
3101
3102    fn get_cached_source_file(
3103        &self,
3104        bucket: &str,
3105        source: &SourceRecord,
3106        relative_path: &str,
3107        expected_hash: Option<&str>,
3108    ) -> Result<CachedBytes> {
3109        let normalized_relative = relative_path.replace('\\', "/");
3110        let cache_path =
3111            temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
3112
3113        if cache_path.exists() {
3114            let cached = fs::read(&cache_path).context("failed to read cached source file")?;
3115            if hash_matches(expected_hash, &cached) {
3116                return Ok(CachedBytes {
3117                    path: cache_path,
3118                    bytes: cached,
3119                });
3120            }
3121        }
3122
3123        let url = format!(
3124            "{}/{}",
3125            source.arg.trim_end_matches('/'),
3126            normalized_relative.trim_start_matches('/')
3127        );
3128        let response = self
3129            .client
3130            .get(url)
3131            .send()
3132            .context("failed to fetch source file")?
3133            .error_for_status()
3134            .context("source file request returned an error")?;
3135        let bytes = response.bytes().context("failed to read source file body")?.to_vec();
3136
3137        if let Some(hash) = expected_hash {
3138            verify_hash(hash, &bytes)?;
3139        }
3140
3141        if let Some(parent) = cache_path.parent() {
3142            fs::create_dir_all(parent).context("failed to create cache directory")?;
3143        }
3144        fs::write(&cache_path, &bytes).context("failed to write cache file")?;
3145
3146        Ok(CachedBytes {
3147            path: cache_path,
3148            bytes,
3149        })
3150    }
3151
3152    fn get_or_fetch_rest_manifest(
3153        &mut self,
3154        source: &SourceRecord,
3155        package_id: &str,
3156        version: &str,
3157        channel: &str,
3158    ) -> Result<(Vec<u8>, PathBuf)> {
3159        let cache_path = rest_manifest_cache_path_with_root(&self.app_root, source, package_id, version, channel);
3160        if cache_path.exists() {
3161            return Ok((
3162                fs::read(&cache_path).context("failed to read cached REST manifest")?,
3163                cache_path,
3164            ));
3165        }
3166
3167        let info = self.load_rest_information_by_name(&source.name)?;
3168        let contract = choose_contract(&info.server_supported_versions)
3169            .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3170        let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
3171
3172        let mut params = vec![("Version", version.to_owned())];
3173        if !channel.is_empty() {
3174            params.push(("Channel", channel.to_owned()));
3175        }
3176        if info
3177            .required_query_parameters
3178            .iter()
3179            .any(|value| value.eq_ignore_ascii_case("Market"))
3180        {
3181            params.push(("Market", default_market()));
3182        }
3183
3184        let response = self
3185            .client
3186            .get(url)
3187            .header("Version", contract)
3188            .query(&params)
3189            .send()
3190            .context("REST manifest request failed")?
3191            .error_for_status()
3192            .context("REST manifest request returned an error")?;
3193        let bytes = response
3194            .bytes()
3195            .context("failed to read REST manifest response")?
3196            .to_vec();
3197
3198        if let Some(parent) = cache_path.parent() {
3199            fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
3200        }
3201        fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
3202
3203        Ok((bytes, cache_path))
3204    }
3205
3206    fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
3207        let index = self
3208            .store
3209            .sources
3210            .iter()
3211            .position(|source| source.name.eq_ignore_ascii_case(name))
3212            .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3213        self.load_rest_information(index)
3214    }
3215
3216    fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
3217        if let Some(name) = source_name {
3218            let index = self
3219                .store
3220                .sources
3221                .iter()
3222                .position(|source| source.name.eq_ignore_ascii_case(name))
3223                .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3224            return Ok(vec![index]);
3225        }
3226
3227        let mut indexes: Vec<_> = self
3228            .store
3229            .sources
3230            .iter()
3231            .enumerate()
3232            .filter(|(_, source)| !source.explicit)
3233            .collect();
3234        indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
3235            right_source
3236                .priority
3237                .cmp(&left_source.priority)
3238                .then_with(|| left_index.cmp(right_index))
3239        });
3240        Ok(indexes.into_iter().map(|(index, _)| index).collect())
3241    }
3242
3243    fn source_clone(&self, index: usize) -> SourceRecord {
3244        self.store.sources[index].clone()
3245    }
3246
3247    fn refresh_system_winget_sources(&mut self) -> Result<()> {
3248        if self.use_system_winget_sources {
3249            self.store = load_system_winget_source_store()?;
3250        }
3251        Ok(())
3252    }
3253
3254    fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
3255        self.refresh_system_winget_sources()?;
3256        let selected_names: Vec<_> = if source_name.is_none() {
3257            self.store.sources.iter().map(|source| source.name.clone()).collect()
3258        } else {
3259            self.resolve_source_indexes(source_name)?
3260                .into_iter()
3261                .map(|index| self.store.sources[index].name.clone())
3262                .collect()
3263        };
3264
3265        let detail = system_winget_update_sources(source_name)?;
3266        self.refresh_system_winget_sources()?;
3267
3268        Ok(selected_names
3269            .into_iter()
3270            .filter_map(|name| {
3271                self.store
3272                    .sources
3273                    .iter()
3274                    .find(|source| source.name.eq_ignore_ascii_case(&name))
3275                    .map(|source| SourceUpdateResult {
3276                        name: source.name.clone(),
3277                        kind: source.kind,
3278                        detail: detail.clone(),
3279                    })
3280            })
3281            .collect())
3282    }
3283}
3284
3285#[derive(Debug)]
3286struct CachedBytes {
3287    path: PathBuf,
3288    bytes: Vec<u8>,
3289}
3290
3291#[derive(Debug, Clone)]
3292struct V1SearchRow {
3293    manifest_rowid: i64,
3294    package_rowid: i64,
3295    version: String,
3296    channel: String,
3297    id: String,
3298    name: String,
3299    moniker: Option<String>,
3300    match_criteria: Option<String>,
3301}
3302
3303#[derive(Debug, Clone)]
3304struct V1VersionRow {
3305    version: String,
3306    channel: String,
3307    pathpart_id: i64,
3308    manifest_hash: Option<String>,
3309}
3310
3311#[derive(Debug, Clone)]
3312struct V2SearchRow {
3313    package_rowid: i64,
3314    package_hash: String,
3315    id: String,
3316    name: String,
3317    moniker: Option<String>,
3318    version: String,
3319    match_criteria: Option<String>,
3320}
3321
3322fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
3323    query.query.is_some()
3324        || query.id.is_some()
3325        || query.name.is_some()
3326        || query.moniker.is_some()
3327        || query.tag.is_some()
3328        || query.command.is_some()
3329        || query.source.is_some()
3330}
3331
3332fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
3333    PackageQuery {
3334        query: query.query.clone(),
3335        id: query.id.clone(),
3336        name: query.name.clone(),
3337        moniker: query.moniker.clone(),
3338        tag: query.tag.clone(),
3339        command: query.command.clone(),
3340        source: query.source.clone(),
3341        count: Some(LIST_LOOKUP_MAX_RESULTS),
3342        exact: query.exact,
3343        version: None,
3344        channel: None,
3345        locale: None,
3346        installer_type: None,
3347        installer_architecture: None,
3348        platform: None,
3349        os_version: None,
3350        install_scope: query.install_scope.clone(),
3351    }
3352}
3353
3354fn allow_loose_list_correlation(query: &ListQuery) -> bool {
3355    query.query.is_some() || query.id.is_some() || query.name.is_some()
3356}
3357
3358fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
3359    package
3360        .correlated
3361        .as_ref()
3362        .and_then(|candidate| candidate.version.as_deref())
3363        .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
3364}
3365
3366fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
3367    package.installed_version.eq_ignore_ascii_case("Unknown")
3368}
3369
3370fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
3371    // Hide RequireExplicitUpgrade rows from bulk `upgrade` (Edge, Steam,
3372    // Discord). Explicit `--id` filters surface them regardless.
3373    if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) {
3374        return false;
3375    }
3376    // Hide rows whose latest catalog version has no installer the host can
3377    // run — winget refuses to upgrade past an unusable architecture.
3378    if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) {
3379        return false;
3380    }
3381    installed_package_has_upgrade(package)
3382        || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
3383}
3384
3385/// Collapses installed entries that correlate to the same catalog package id
3386/// (case-insensitive within a source) down to a single representative — the
3387/// one with the highest installed version. Uncorrelated entries pass through
3388/// untouched; they can't appear in `upgrade` output anyway (the upgrade
3389/// filter requires a correlation), so they're effectively a no-op here.
3390fn dedupe_correlated_for_upgrade(packages: Vec<InstalledPackage>) -> Vec<InstalledPackage> {
3391    let mut by_id: HashMap<(String, String), InstalledPackage> = HashMap::new();
3392    let mut uncorrelated: Vec<InstalledPackage> = Vec::new();
3393
3394    for package in packages {
3395        match package.correlated.as_ref() {
3396            Some(candidate) => {
3397                let key = (
3398                    candidate.id.to_ascii_lowercase(),
3399                    candidate.source_name.to_ascii_lowercase(),
3400                );
3401                let keep_new = match by_id.get(&key) {
3402                    Some(existing) => {
3403                        // Prefer rows whose `installed_version` was remapped to
3404                        // a catalog Version (via aMiV/aMaV). Comparing a raw
3405                        // ARP DisplayVersion like `40.10.18029` against the
3406                        // catalog's `10.0.300` is meaningless and would also
3407                        // hide the row that legitimately upgrades to it.
3408                        match (
3409                            package.installed_version_canonical,
3410                            existing.installed_version_canonical,
3411                        ) {
3412                            (true, false) => true,
3413                            (false, true) => false,
3414                            _ => {
3415                                compare_version(&package.installed_version, &existing.installed_version)
3416                                    == Ordering::Greater
3417                            }
3418                        }
3419                    }
3420                    None => true,
3421                };
3422                if keep_new {
3423                    by_id.insert(key, package);
3424                }
3425            }
3426            None => uncorrelated.push(package),
3427        }
3428    }
3429
3430    let mut result = uncorrelated;
3431    result.extend(by_id.into_values());
3432    result
3433}
3434
3435fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
3436    let mut source_specific = None;
3437    let mut source_agnostic = None;
3438
3439    for pin in pins {
3440        if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
3441            continue;
3442        }
3443
3444        if pin.source_id.is_empty() {
3445            if source_agnostic.is_none() {
3446                source_agnostic = Some(pin);
3447            }
3448        } else if item
3449            .source_name
3450            .as_deref()
3451            .is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
3452        {
3453            source_specific = Some(pin);
3454            break;
3455        }
3456    }
3457
3458    source_specific.or(source_agnostic)
3459}
3460
3461fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
3462    let Some(available_version) = item.available_version.as_deref() else {
3463        return false;
3464    };
3465    let Some(pin) = find_applicable_pin(item, pins) else {
3466        return false;
3467    };
3468
3469    match pin.pin_type {
3470        PinType::Blocking => true,
3471        PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
3472    }
3473}
3474
3475fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
3476    if pattern.trim().is_empty() || pattern == "*" {
3477        return true;
3478    }
3479
3480    if !pattern.contains('*') {
3481        return version.eq_ignore_ascii_case(pattern);
3482    }
3483
3484    if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
3485        let prefix = &pattern[..pattern.len() - 1];
3486        return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
3487    }
3488
3489    version.eq_ignore_ascii_case(pattern)
3490}
3491
3492fn list_match_from_installed(package: InstalledPackage) -> ListMatch {
3493    let available_version = package.correlated.as_ref().and_then(|candidate| {
3494        candidate.version.as_ref().and_then(|candidate_version| {
3495            if installed_package_has_unknown_version(&package)
3496                || compare_version(candidate_version, &package.installed_version) == Ordering::Greater
3497            {
3498                Some(candidate_version.clone())
3499            } else {
3500                None
3501            }
3502        })
3503    });
3504    let source_name = package
3505        .correlated
3506        .as_ref()
3507        .map(|candidate| candidate.source_name.clone())
3508        .filter(|value| !value.is_empty());
3509    let id = package
3510        .correlated
3511        .as_ref()
3512        .map(|candidate| candidate.id.clone())
3513        .unwrap_or_else(|| package.local_id.clone());
3514
3515    ListMatch {
3516        name: package.name,
3517        id,
3518        local_id: package.local_id,
3519        installed_version: package.installed_version,
3520        available_version,
3521        source_name,
3522        publisher: package.publisher,
3523        scope: package.scope,
3524        installer_category: package.installer_category,
3525        install_location: package.install_location,
3526        package_family_names: package.package_family_names,
3527        product_codes: package.product_codes,
3528        upgrade_codes: package.upgrade_codes,
3529    }
3530}
3531
3532fn list_sort_weight(package: &InstalledPackage) -> usize {
3533    if package.local_id.starts_with("ARP\\") {
3534        0
3535    } else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
3536        1
3537    } else {
3538        2
3539    }
3540}
3541
3542fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
3543    let correlated = package.correlated.as_ref();
3544
3545    if let Some(value) = &query.id {
3546        let local_match = matches_text(&package.local_id, value, query.exact);
3547        let correlated_match = correlated
3548            .map(|candidate| matches_text(&candidate.id, value, query.exact))
3549            .unwrap_or(false);
3550        if !local_match && !correlated_match {
3551            return false;
3552        }
3553    }
3554
3555    if let Some(value) = &query.name
3556        && !matches_text(&package.name, value, query.exact)
3557    {
3558        return false;
3559    }
3560
3561    if let Some(value) = &query.product_code
3562        && !package
3563            .product_codes
3564            .iter()
3565            .any(|code| code.eq_ignore_ascii_case(value))
3566    {
3567        return false;
3568    }
3569
3570    if let Some(value) = &query.version
3571        && !package.installed_version.eq_ignore_ascii_case(value)
3572    {
3573        return false;
3574    }
3575
3576    if let Some(value) = &query.query {
3577        let local_match =
3578            matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
3579        let correlated_match = correlated
3580            .map(|candidate| {
3581                matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
3582            })
3583            .unwrap_or(false);
3584        if !local_match && !correlated_match {
3585            return false;
3586        }
3587    }
3588
3589    if let Some(source) = &query.source
3590        && correlated
3591            .map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
3592            .unwrap_or(true)
3593    {
3594        return false;
3595    }
3596
3597    if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
3598        return false;
3599    }
3600
3601    true
3602}
3603
3604fn correlate_installed_package(
3605    package: &InstalledPackage,
3606    candidates: &[SearchMatch],
3607    allow_loose_name_match: bool,
3608) -> Option<SearchMatch> {
3609    // MSIX entries can only be correlated through their PackageFamilyName.
3610    // That lookup happens earlier in `correlate_installed_via_index`; if it
3611    // didn't find a match, name-based fallback is wrong — the catalog
3612    // doesn't carry this MSIX's identity, so any name collision (e.g. the
3613    // self-updating `Microsoft Edge Stable` MSIX or the Store stub
3614    // `Notepad++` MSIX) would manufacture a phantom correlation that winget
3615    // doesn't make.
3616    if package.local_id.starts_with("MSIX\\") {
3617        return None;
3618    }
3619
3620    let installed_name = normalize_correlation_name(&package.name);
3621    let installed_name_lower = package.name.to_ascii_lowercase();
3622    let candidate_names = correlation_name_candidates(&package.name);
3623
3624    let mut scored: Vec<(i32, SearchMatch)> = candidates
3625        .iter()
3626        .filter_map(|candidate| {
3627            let candidate_name = normalize_correlation_name(&candidate.name);
3628            let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
3629                1000
3630            } else if candidate_names.iter().any(|name| {
3631                let normalized = normalize_correlation_name(name);
3632                normalized == candidate_name
3633            }) {
3634                900
3635            } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
3636                700
3637            } else {
3638                0
3639            };
3640
3641            if base_score == 0 {
3642                return None;
3643            }
3644
3645            // Tiebreaker so we pick the *right* candidate when multiple catalog
3646            // entries collapse to the same normalized name or all loose-match a
3647            // long installed name. Two failure modes this addresses:
3648            //   • `Notepad++` and `Notepad--` both normalize to `notepad` (the
3649            //     alphanumeric filter strips `+`/`-`), so both score 900 and
3650            //     `max_by_key` arbitrarily picked the latter.
3651            //   • Loose substring match picks up `Studio` inside
3652            //     `...from Visual Studio` and lets `ZeroBrane.Studio` outrank
3653            //     `Microsoft.DotNet.SDK.10` when both score 700.
3654            // The bonus rewards catalog names that appear verbatim in the
3655            // installed display name — strongly when they anchor the start,
3656            // weaker when they're embedded.
3657            let candidate_lower = candidate.name.to_ascii_lowercase();
3658            let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
3659                100
3660            } else if installed_name_lower.contains(&candidate_lower) {
3661                50
3662            } else {
3663                0
3664            };
3665
3666            Some((base_score + prefix_bonus, candidate.clone()))
3667        })
3668        .collect();
3669
3670    scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
3671    let mut iter = scored.into_iter();
3672    let (top_score, top_match) = iter.next()?;
3673
3674    // Reject ambiguous wins. When the user has e.g. `Git` (publisher "The Git
3675    // Development Community") installed, both `Git.Git` and `Microsoft.Git`
3676    // normalize their names to `git` and tie at the same score — the only
3677    // signal that disambiguates them, publisher, isn't part of this scoring
3678    // function. winget refuses the correlation in this case (the install
3679    // shows up with an empty Source); we do the same to avoid manufacturing
3680    // a fake upgrade against the wrong package.
3681    if let Some((next_score, _)) = iter.next()
3682        && next_score == top_score
3683    {
3684        return None;
3685    }
3686    Some(top_match)
3687}
3688
3689fn correlation_name_candidates(name: &str) -> Vec<String> {
3690    let mut candidates = Vec::new();
3691    candidates.push(name.trim().to_owned());
3692
3693    let mut trimmed = name.trim().to_owned();
3694    if let Some(index) = trimmed.find(" (") {
3695        trimmed.truncate(index);
3696    }
3697
3698    let mut words = Vec::new();
3699    for token in trimmed.split_whitespace() {
3700        let lower = token
3701            .trim_matches(|ch: char| ch == '(' || ch == ')')
3702            .to_ascii_lowercase();
3703        if !words.is_empty()
3704            && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
3705        {
3706            break;
3707        }
3708        words.push(token);
3709    }
3710
3711    if !words.is_empty() {
3712        candidates.push(words.join(" "));
3713    }
3714
3715    candidates.sort();
3716    candidates.dedup();
3717    candidates
3718}
3719
3720fn normalize_correlation_name(value: &str) -> String {
3721    value
3722        .chars()
3723        .filter(|ch| ch.is_ascii_alphanumeric())
3724        .flat_map(|ch| ch.to_lowercase())
3725        .collect()
3726}
3727
3728struct V2PackageMetadata {
3729    rowid: i64,
3730    id: String,
3731    name: String,
3732    moniker: Option<String>,
3733    latest_version: String,
3734    package_hash: String,
3735}
3736
3737/// Looks up a v2 catalog package whose identity (PackageFamilyName, ProductCode,
3738/// or UpgradeCode) matches the installed package. Returns the `packages.rowid`
3739/// and the field that produced the match. This is winget's primary correlation
3740/// path; falling back to display-name matching is only correct for ARP entries
3741/// that lack all three identifiers.
3742fn lookup_identity_match_v2(
3743    connection: &Connection,
3744    package: &InstalledPackage,
3745) -> Result<Option<(i64, &'static str)>> {
3746    for pfn in &package.package_family_names {
3747        if let Some(rowid) = query_optional_value(
3748            connection,
3749            "SELECT package FROM pfns2 WHERE pfn = ?1 LIMIT 1",
3750            vec![SqlValue::Text(pfn.to_ascii_lowercase())],
3751            |row| row_i64(row, 0),
3752        )? {
3753            return Ok(Some((rowid, "PackageFamilyName")));
3754        }
3755    }
3756    // UpgradeCode wins over ProductCode. The UpgradeCode is the MSI *family*
3757    // identity — it stays stable across versions, while ProductCode changes
3758    // every release. When a single ProductCode happens to also be listed
3759    // under a sibling catalog package (e.g. `OpenJS.NodeJS.LTS` carries
3760    // installer rows from earlier LTS-line ProductCodes, while the v24
3761    // install actually belongs to `OpenJS.NodeJS.22` via its UpgradeCode),
3762    // the upgrade-side match represents the user's "package family" — the
3763    // one winget uses.
3764    for code in &package.upgrade_codes {
3765        if let Some(rowid) = query_optional_value(
3766            connection,
3767            "SELECT package FROM upgradecodes2 WHERE upgradecode = ?1 LIMIT 1",
3768            vec![SqlValue::Text(code.to_ascii_lowercase())],
3769            |row| row_i64(row, 0),
3770        )? {
3771            return Ok(Some((rowid, "UpgradeCode")));
3772        }
3773    }
3774    for code in &package.product_codes {
3775        if let Some(rowid) = query_optional_value(
3776            connection,
3777            "SELECT package FROM productcodes2 WHERE productcode = ?1 LIMIT 1",
3778            vec![SqlValue::Text(code.to_ascii_lowercase())],
3779            |row| row_i64(row, 0),
3780        )? {
3781            return Ok(Some((rowid, "ProductCode")));
3782        }
3783    }
3784    Ok(None)
3785}
3786
3787/// Resolves a v2 package by its catalog id back to its rowid + hash so the
3788/// post-correlation enrichment pass can load versionData for rows that
3789/// correlated through the name-based fallback (which throws the rowid away).
3790fn lookup_v2_metadata_by_id(connection: &Connection, package_id: &str) -> Result<Option<V2PackageMetadata>> {
3791    query_optional_value(
3792        connection,
3793        "SELECT rowid, id, name, moniker, latest_version, hash FROM packages WHERE id = ?1 COLLATE NOCASE LIMIT 1",
3794        vec![SqlValue::Text(package_id.to_owned())],
3795        |row| {
3796            Ok(V2PackageMetadata {
3797                rowid: row_i64(row, 0)?,
3798                id: row_string(row, 1)?,
3799                name: row_string(row, 2)?,
3800                moniker: row_opt_string(row, 3)?,
3801                latest_version: row_string(row, 4)?,
3802                package_hash: row_hex_string(row, 5)?,
3803            })
3804        },
3805    )
3806}
3807
3808/// MSIX entries whose Windows resource-string display name (e.g.
3809/// `ms-resource:appDisplayName`) hasn't been resolved by the AppX runtime
3810/// would otherwise leak that placeholder into pinget's output. winget
3811/// resolves it via `Windows.Management.Deployment` and shows the catalog's
3812/// `PackageName`; we mirror that by substituting the correlated catalog
3813/// name once the package is correlated, which is the parity-faithful
3814/// substitute we can produce without taking a WinRT dependency.
3815fn apply_msix_resource_string_name_fix(package: &mut InstalledPackage) {
3816    if !package.local_id.starts_with("MSIX\\") {
3817        return;
3818    }
3819    if !package.name.starts_with("ms-resource:") {
3820        return;
3821    }
3822    let Some(candidate) = package.correlated.as_ref() else {
3823        return;
3824    };
3825    if !candidate.name.is_empty() {
3826        package.name = candidate.name.clone();
3827    }
3828}
3829
3830fn fetch_v2_package_metadata(connection: &Connection, package_rowid: i64) -> Result<Option<V2PackageMetadata>> {
3831    query_optional_value(
3832        connection,
3833        "SELECT id, name, moniker, latest_version, hash FROM packages WHERE rowid = ?1",
3834        vec![SqlValue::Integer(package_rowid)],
3835        |row| {
3836            Ok(V2PackageMetadata {
3837                rowid: package_rowid,
3838                id: row_string(row, 0)?,
3839                name: row_string(row, 1)?,
3840                moniker: row_opt_string(row, 2)?,
3841                latest_version: row_string(row, 3)?,
3842                package_hash: row_hex_string(row, 4)?,
3843            })
3844        },
3845    )
3846}
3847
3848fn v2_normalized_identity_tables_present(connection: &Connection) -> bool {
3849    query_optional_value(
3850        connection,
3851        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'norm_names2' LIMIT 1",
3852        Vec::new(),
3853        |row| row_i64(row, 0),
3854    )
3855    .ok()
3856    .flatten()
3857    .is_some()
3858}
3859
3860/// Returns the catalog `packages.rowid` whose `(norm_name, norm_publisher)`
3861/// pair matches the installed package — if and only if the match is
3862/// unique. Multiple matches mean the normalization wasn't strong enough to
3863/// disambiguate (e.g. an ARP DisplayName that normalizes the same as two
3864/// distinct catalog packages); winget refuses to correlate in that case
3865/// and we do the same.
3866fn lookup_unique_normalized_identity(
3867    connection: &Connection,
3868    norm_name: &str,
3869    norm_publisher: &str,
3870) -> Result<Option<i64>> {
3871    let rows: Vec<i64> = query_rows(
3872        connection,
3873        "SELECT DISTINCT n.package FROM norm_names2 n \
3874         WHERE n.norm_name = ?1 \
3875         AND EXISTS (SELECT 1 FROM norm_publishers2 p WHERE p.package = n.package AND p.norm_publisher = ?2) \
3876         LIMIT 2",
3877        vec![
3878            SqlValue::Text(norm_name.to_owned()),
3879            SqlValue::Text(norm_publisher.to_owned()),
3880        ],
3881        |row| row_i64(row, 0),
3882    )?;
3883    if rows.len() == 1 { Ok(Some(rows[0])) } else { Ok(None) }
3884}
3885
3886fn v2_identity_tables_present(connection: &Connection) -> bool {
3887    query_optional_value(
3888        connection,
3889        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'productcodes2' LIMIT 1",
3890        Vec::new(),
3891        |row| row_i64(row, 0),
3892    )
3893    .ok()
3894    .flatten()
3895    .is_some()
3896}
3897
3898/// Translates an ARP DisplayVersion to the catalog Version it maps to.
3899/// For packages like the .NET SDK or Python the ARP version is an MSI build
3900/// number (e.g. `10.1.826.23019`) unrelated to the public version
3901/// (`10.0.108`). winget records the ARP range per catalog version in
3902/// versionData.mszyml as `aMiV` / `aMaV`; we look for the entry whose range
3903/// contains the installed version.
3904fn map_arp_version_to_catalog(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
3905    if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
3906        return None;
3907    }
3908    for entry in entries {
3909        let (Some(min), Some(max)) = (entry.arp_min_version.as_deref(), entry.arp_max_version.as_deref()) else {
3910            continue;
3911        };
3912        if compare_version(arp_version, min) != Ordering::Less && compare_version(arp_version, max) != Ordering::Greater
3913        {
3914            return Some(entry.version.clone());
3915        }
3916    }
3917    None
3918}
3919
3920/// Returns the latest catalog Version that carries ARP-range metadata
3921/// (`aMiV` / `aMaV`). Packages like `Microsoft.WindowsAppRuntime.1.8` also
3922/// publish "internal" version rows whose `v` is an MSI build number
3923/// (`8000.836.2153.0`) without ARP bounds. Those rows are not user-facing
3924/// upgrade targets — when the installed side was matched through an ARP
3925/// range, the available side must be a peer that also exposes ARP bounds so
3926/// the two versions compare on the same scale. Returns `None` when no entry
3927/// has bounds; the caller should fall back to `packages.latest_version`.
3928fn latest_arp_anchored_version(entries: &[PackageVersionDataEntry]) -> Option<String> {
3929    entries
3930        .iter()
3931        .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
3932        .max_by(|a, b| compare_version(&a.version, &b.version))
3933        .map(|entry| entry.version.clone())
3934}
3935
3936#[cfg(windows)]
3937fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
3938    let mut packages = Vec::new();
3939    let mut seen = BTreeSet::new();
3940
3941    let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
3942    let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
3943
3944    // MSI registers UpgradeCode → ProductCode mappings under
3945    // `HKLM\SOFTWARE\Classes\Installer\UpgradeCodes` (per-machine) and
3946    // `HKCU\Software\Microsoft\Installer\UpgradeCodes` (per-user). Most ARP
3947    // entries don't expose `UpgradeCode` directly, so winget reads it from
3948    // here. Without it, packages like `OpenJS.NodeJS.22` (correlated only via
3949    // UpgradeCode in the v2 index) fall back to a sibling correlation (e.g.
3950    // `OpenJS.NodeJS.LTS`) that manufactures a spurious upgrade.
3951    let upgrade_codes = collect_msi_upgrade_codes(machine, user);
3952
3953    if machine {
3954        collect_uninstall_view(
3955            &mut packages,
3956            &mut seen,
3957            &upgrade_codes,
3958            RegKey::predef(HKEY_LOCAL_MACHINE),
3959            "Machine",
3960            "X64",
3961            KEY_READ | KEY_WOW64_64KEY,
3962        )?;
3963        collect_uninstall_view(
3964            &mut packages,
3965            &mut seen,
3966            &upgrade_codes,
3967            RegKey::predef(HKEY_LOCAL_MACHINE),
3968            "Machine",
3969            "X86",
3970            KEY_READ | KEY_WOW64_32KEY,
3971        )?;
3972        collect_appmodel_packages(
3973            &mut packages,
3974            &mut seen,
3975            RegKey::predef(HKEY_LOCAL_MACHINE),
3976            "Machine",
3977            KEY_READ | KEY_WOW64_64KEY,
3978        )?;
3979    }
3980
3981    if user {
3982        collect_uninstall_view(
3983            &mut packages,
3984            &mut seen,
3985            &upgrade_codes,
3986            RegKey::predef(HKEY_CURRENT_USER),
3987            "User",
3988            "X64",
3989            KEY_READ,
3990        )?;
3991        collect_appmodel_packages(
3992            &mut packages,
3993            &mut seen,
3994            RegKey::predef(HKEY_CURRENT_USER),
3995            "User",
3996            KEY_READ,
3997        )?;
3998    }
3999
4000    Ok(packages)
4001}
4002
4003/// Builds a `ProductCode → UpgradeCode` map (both in standard
4004/// `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}` lowercase format) by walking the
4005/// MSI Installer registry. Subkeys under `Installer\UpgradeCodes` are named
4006/// by the flipped UpgradeCode GUID, and each subkey's *value names* are the
4007/// flipped ProductCodes registered under that UpgradeCode.
4008#[cfg(windows)]
4009fn collect_msi_upgrade_codes(include_machine: bool, include_user: bool) -> HashMap<String, String> {
4010    let mut map = HashMap::new();
4011    if include_machine {
4012        collect_msi_upgrade_codes_from(
4013            RegKey::predef(HKEY_LOCAL_MACHINE),
4014            r"SOFTWARE\Classes\Installer\UpgradeCodes",
4015            KEY_READ | KEY_WOW64_64KEY,
4016            &mut map,
4017        );
4018    }
4019    if include_user {
4020        collect_msi_upgrade_codes_from(
4021            RegKey::predef(HKEY_CURRENT_USER),
4022            r"Software\Microsoft\Installer\UpgradeCodes",
4023            KEY_READ,
4024            &mut map,
4025        );
4026    }
4027    map
4028}
4029
4030#[cfg(windows)]
4031fn collect_msi_upgrade_codes_from(root: RegKey, path: &str, flags: u32, map: &mut HashMap<String, String>) {
4032    let Ok(upgrade_codes) = root.open_subkey_with_flags(path, flags) else {
4033        return;
4034    };
4035    for key_name in upgrade_codes.enum_keys().flatten() {
4036        let Some(upgrade_code) = unflip_packed_guid(&key_name) else {
4037            continue;
4038        };
4039        let Ok(subkey) = upgrade_codes.open_subkey_with_flags(&key_name, flags) else {
4040            continue;
4041        };
4042        for (value_name, _) in subkey.enum_values().flatten() {
4043            if let Some(product_code) = unflip_packed_guid(&value_name) {
4044                // ARP-side keys are likely to win the latest insert; first one
4045                // is fine — we just need *any* mapping. Use `entry` so the
4046                // first writer per ProductCode wins to stay deterministic.
4047                map.entry(product_code).or_insert_with(|| upgrade_code.clone());
4048            }
4049        }
4050    }
4051}
4052
4053/// Converts the 32-character "packed GUID" used inside the MSI Installer
4054/// registry hive back to the standard `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}`
4055/// lowercase form. The packing reverses each of the 11 chunks (sized 8/4/4
4056/// then eight 2-char byte pairs) of the GUID's hex representation.
4057#[cfg(windows)]
4058fn unflip_packed_guid(packed: &str) -> Option<String> {
4059    if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) {
4060        return None;
4061    }
4062    const CHUNKS: [usize; 11] = [8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2];
4063    let bytes = packed.as_bytes();
4064    let mut reversed = String::with_capacity(32);
4065    let mut offset = 0;
4066    for size in CHUNKS {
4067        let chunk = &bytes[offset..offset + size];
4068        for &c in chunk.iter().rev() {
4069            reversed.push(c as char);
4070        }
4071        offset += size;
4072    }
4073    let r = reversed.to_ascii_lowercase();
4074    Some(format!(
4075        "{{{}-{}-{}-{}-{}}}",
4076        &r[0..8],
4077        &r[8..12],
4078        &r[12..16],
4079        &r[16..20],
4080        &r[20..32]
4081    ))
4082}
4083
4084#[cfg(not(windows))]
4085fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
4086    Ok(Vec::new())
4087}
4088
4089#[cfg(windows)]
4090fn collect_uninstall_view(
4091    packages: &mut Vec<InstalledPackage>,
4092    seen: &mut BTreeSet<String>,
4093    upgrade_code_map: &HashMap<String, String>,
4094    root: RegKey,
4095    scope: &str,
4096    arch: &str,
4097    flags: u32,
4098) -> Result<()> {
4099    const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
4100
4101    let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
4102        Ok(key) => key,
4103        Err(_) => return Ok(()),
4104    };
4105
4106    for key_name in uninstall.enum_keys().flatten() {
4107        let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
4108            Ok(key) => key,
4109            Err(_) => continue,
4110        };
4111        if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
4112        {
4113            continue;
4114        }
4115
4116        let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4117            continue;
4118        };
4119
4120        let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
4121        let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
4122        let publisher = read_reg_string(&subkey, "Publisher");
4123        let install_location = read_reg_string(&subkey, "InstallLocation");
4124        let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
4125            .into_iter()
4126            .collect::<Vec<_>>();
4127        let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
4128        if product_codes.is_empty() && looks_like_product_code(&key_name) {
4129            product_codes.push(key_name.to_ascii_lowercase());
4130        }
4131        let mut upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
4132        if upgrade_codes.is_empty() {
4133            // ARP rarely exposes UpgradeCode directly. Recover it from the MSI
4134            // Installer registry hive so identity correlation can fall back to
4135            // upgradecodes2 when productcodes2 picks the wrong sibling.
4136            for code in &product_codes {
4137                if let Some(uc) = upgrade_code_map.get(&code.to_ascii_lowercase()) {
4138                    upgrade_codes.push(uc.clone());
4139                    break;
4140                }
4141            }
4142        }
4143        let installer_category = if read_reg_string(&subkey, "WinGetInstallerType")
4144            .as_deref()
4145            .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
4146        {
4147            // Honor the WinGet ARP signal so uninstall flows through
4148            // uninstall_portable (clean dir + registry removal) instead of
4149            // delegating the WinGet portable UninstallString to a winget
4150            // subprocess that only fully cleans up entries winget itself
4151            // installed.
4152            Some("portable".to_owned())
4153        } else if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
4154            Some("msi".to_owned())
4155        } else if key_name.starts_with("MSIX\\") {
4156            Some("msix".to_owned())
4157        } else {
4158            Some("exe".to_owned())
4159        };
4160
4161        let dedupe_key = format!(
4162            "{}|{}|{}|{}",
4163            local_id,
4164            name.to_ascii_lowercase(),
4165            installed_version.to_ascii_lowercase(),
4166            publisher.clone().unwrap_or_default().to_ascii_lowercase()
4167        );
4168        if !seen.insert(dedupe_key) {
4169            continue;
4170        }
4171
4172        packages.push(InstalledPackage {
4173            name,
4174            local_id,
4175            installed_version,
4176            publisher,
4177            scope: Some(scope.to_owned()),
4178            installer_category,
4179            install_location,
4180            package_family_names,
4181            product_codes,
4182            upgrade_codes,
4183            correlated: None,
4184            installed_version_canonical: false,
4185            correlated_requires_explicit_upgrade: false,
4186            correlated_lacks_compatible_installer: false,
4187        });
4188    }
4189
4190    Ok(())
4191}
4192
4193#[cfg(windows)]
4194fn collect_appmodel_packages(
4195    packages: &mut Vec<InstalledPackage>,
4196    seen: &mut BTreeSet<String>,
4197    root: RegKey,
4198    scope: &str,
4199    flags: u32,
4200) -> Result<()> {
4201    const APPMODEL_PACKAGES_PATH: &str =
4202        r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
4203
4204    let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
4205        Ok(key) => key,
4206        Err(_) => return Ok(()),
4207    };
4208
4209    for key_name in appmodel.enum_keys().flatten() {
4210        let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
4211            Ok(key) => key,
4212            Err(_) => continue,
4213        };
4214
4215        let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4216            continue;
4217        };
4218        let install_location = read_reg_string(&subkey, "PackageRootFolder");
4219        if install_location.as_deref().is_some_and(is_windows_system_path) {
4220            continue;
4221        }
4222
4223        let Some(metadata) = parse_msix_package_full_name(&key_name) else {
4224            continue;
4225        };
4226
4227        let local_id = format!(r"MSIX\{key_name}");
4228        let dedupe_key = format!(
4229            "{}|{}|{}",
4230            local_id,
4231            name.to_ascii_lowercase(),
4232            metadata.version.to_ascii_lowercase()
4233        );
4234        if !seen.insert(dedupe_key) {
4235            continue;
4236        }
4237
4238        packages.push(InstalledPackage {
4239            name,
4240            local_id,
4241            installed_version: metadata.version,
4242            publisher: None,
4243            scope: Some(scope.to_owned()),
4244            installer_category: Some("msix".to_owned()),
4245            install_location,
4246            package_family_names: vec![metadata.family_name],
4247            product_codes: Vec::new(),
4248            upgrade_codes: Vec::new(),
4249            correlated: None,
4250            installed_version_canonical: false,
4251            correlated_requires_explicit_upgrade: false,
4252            correlated_lacks_compatible_installer: false,
4253        });
4254    }
4255
4256    Ok(())
4257}
4258
4259#[cfg(windows)]
4260struct ParsedMsixPackageFullName {
4261    version: String,
4262    family_name: String,
4263}
4264
4265#[cfg(windows)]
4266fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
4267    let segments = value.split('_').collect::<Vec<_>>();
4268    if segments.len() < 5 {
4269        return None;
4270    }
4271
4272    let name = segments[..segments.len() - 4].join("_");
4273    if name.is_empty() {
4274        return None;
4275    }
4276
4277    let version = segments[segments.len() - 4].trim();
4278    let resource_id = segments[segments.len() - 2].trim();
4279    let publisher_id = segments[segments.len() - 1].trim();
4280    if version.is_empty() || publisher_id.is_empty() {
4281        return None;
4282    }
4283
4284    let family_name = if resource_id.is_empty() {
4285        format!("{name}_{publisher_id}")
4286    } else {
4287        format!("{name}_{resource_id}_{publisher_id}")
4288    };
4289
4290    Some(ParsedMsixPackageFullName {
4291        version: version.to_owned(),
4292        family_name,
4293    })
4294}
4295
4296#[cfg(windows)]
4297fn is_windows_system_path(path: &str) -> bool {
4298    path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
4299}
4300
4301#[cfg(windows)]
4302fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
4303    key.get_value::<String, _>(value_name)
4304        .ok()
4305        .map(|value| value.trim().to_owned())
4306        .filter(|value| !value.is_empty())
4307}
4308
4309#[cfg(windows)]
4310fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
4311    key.get_value::<u32, _>(value_name).ok()
4312}
4313
4314#[cfg(windows)]
4315fn looks_like_product_code(value: &str) -> bool {
4316    value.starts_with('{') && value.ends_with('}')
4317}
4318
4319fn ensure_app_dirs(app_root: &Path) -> Result<()> {
4320    fs::create_dir_all(app_root).context("failed to create app root directory")?;
4321    if uses_packaged_layout(app_root) {
4322        fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
4323        let user_sources_path = packaged_user_sources_path(app_root)?;
4324        if let Some(parent) = user_sources_path.parent() {
4325            fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
4326        }
4327    } else {
4328        fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
4329    }
4330    Ok(())
4331}
4332
4333fn default_app_root() -> Result<PathBuf> {
4334    if let Some(app_root) = std::env::var_os("PINGET_APPROOT") {
4335        return Ok(PathBuf::from(app_root));
4336    }
4337
4338    let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
4339
4340    #[cfg(windows)]
4341    {
4342        if has_current_package_identity() {
4343            return Ok(local_app_data
4344                .join("Packages")
4345                .join(PACKAGED_FAMILY_NAME)
4346                .join("LocalState"));
4347        }
4348        Ok(local_app_data.join("Devolutions").join("Pinget"))
4349    }
4350
4351    #[cfg(not(windows))]
4352    {
4353        Ok(local_app_data.join("pinget"))
4354    }
4355}
4356
4357#[cfg(windows)]
4358fn has_current_package_identity() -> bool {
4359    // APPMODEL_ERROR_NO_PACKAGE; documented Win32 error returned when the calling
4360    // process has no AppX package identity.
4361    const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
4362
4363    let mut length: u32 = 0;
4364    // SAFETY: GetCurrentPackageFullName accepts a null name buffer when length is 0; it returns
4365    // ERROR_INSUFFICIENT_BUFFER for packaged callers and APPMODEL_ERROR_NO_PACKAGE otherwise.
4366    let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
4367    rc != APPMODEL_ERROR_NO_PACKAGE
4368}
4369
4370fn store_path(app_root: &Path) -> PathBuf {
4371    app_root.join("sources.json")
4372}
4373
4374fn user_settings_path(app_root: &Path) -> PathBuf {
4375    if uses_packaged_layout(app_root) {
4376        app_root.join("settings.json")
4377    } else {
4378        app_root.join("user-settings.json")
4379    }
4380}
4381
4382fn admin_settings_path(app_root: &Path) -> PathBuf {
4383    app_root.join("admin-settings.json")
4384}
4385
4386fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
4387    if uses_packaged_layout(app_root) {
4388        return app_root
4389            .join(packaged_source_type(source.kind))
4390            .join(sanitize_path_segment(&source.identifier));
4391    }
4392
4393    app_root.join("sources").join(sanitize_path_segment(&source.name))
4394}
4395
4396fn preindexed_package_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
4397    source_state_dir(app_root, source).join("source.msix")
4398}
4399
4400fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
4401    source_state_dir(app_root, source).join("index.db")
4402}
4403
4404/// Removes the `-wal` and `-shm` sidecars that SQLite/Turso leaves next to a
4405/// database file. Used after replacing `index.db` so a stale write-ahead log
4406/// from the previous database is not replayed against the new one.
4407fn remove_sqlite_sidecars(db_path: &Path) {
4408    for suffix in ["-wal", "-shm"] {
4409        let mut sidecar = db_path.as_os_str().to_owned();
4410        sidecar.push(suffix);
4411        let _ = fs::remove_file(PathBuf::from(sidecar));
4412    }
4413}
4414
4415#[cfg(windows)]
4416const REPLACE_FILE_RETRY_ATTEMPTS: u32 = 5;
4417
4418#[cfg(windows)]
4419fn move_replace_existing(source: &Path, target: &Path) -> std::io::Result<()> {
4420    let source_wide = source
4421        .as_os_str()
4422        .encode_wide()
4423        .chain(std::iter::once(0))
4424        .collect::<Vec<_>>();
4425    let target_wide = target
4426        .as_os_str()
4427        .encode_wide()
4428        .chain(std::iter::once(0))
4429        .collect::<Vec<_>>();
4430    let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
4431    // SAFETY: Both paths are converted to null-terminated UTF-16 buffers that
4432    // live for the duration of the call; MoveFileExW does not retain them.
4433    let ok = unsafe { MoveFileExW(source_wide.as_ptr(), target_wide.as_ptr(), flags) };
4434    if ok == 0 {
4435        return Err(std::io::Error::last_os_error());
4436    }
4437    Ok(())
4438}
4439
4440#[cfg(windows)]
4441fn replace_file(source: &Path, target: &Path) -> Result<()> {
4442    let to_err = |error: std::io::Error| -> anyhow::Error {
4443        anyhow::Error::new(error).context(format!(
4444            "failed to replace {} with {}",
4445            target.display(),
4446            source.display()
4447        ))
4448    };
4449
4450    // Fast path: atomic replace. Succeeds whenever nothing holds the target open.
4451    let first = match move_replace_existing(source, target) {
4452        Ok(()) => return Ok(()),
4453        Err(error) => error,
4454    };
4455
4456    // A missing source or absent target is not a transient lock — fail fast
4457    // instead of spinning through the retry/stage-aside path.
4458    if !source.exists() || !target.exists() {
4459        return Err(to_err(first));
4460    }
4461
4462    // MOVEFILE_REPLACE_EXISTING is denied while another handle holds the target
4463    // open. In practice that handle is an on-access AV scan of the freshly
4464    // written file or a concurrent pinget process reading the index; both are
4465    // short-lived, so retry briefly with a small backoff before falling back.
4466    let mut last = first;
4467    for attempt in 1..=REPLACE_FILE_RETRY_ATTEMPTS {
4468        std::thread::sleep(std::time::Duration::from_millis(50 * u64::from(attempt)));
4469        match move_replace_existing(source, target) {
4470            Ok(()) => return Ok(()),
4471            Err(error) => last = error,
4472        }
4473    }
4474
4475    // Persistent holder: stage the existing index aside, then move the new file
4476    // into the freed name. Renaming the existing file succeeds even while a
4477    // reader keeps its handle, because SQLite/Turso opens with FILE_SHARE_DELETE.
4478    // The staged copy is only removed once the new file is in place, and restored
4479    // if the swap fails, so a working file is never lost.
4480    let staged = target.with_extension("old.tmp");
4481    if move_replace_existing(target, &staged).is_err() {
4482        return Err(to_err(last));
4483    }
4484    match move_replace_existing(source, target) {
4485        Ok(()) => {
4486            let _ = fs::remove_file(&staged);
4487            Ok(())
4488        }
4489        Err(error) => {
4490            let _ = move_replace_existing(&staged, target);
4491            Err(to_err(error))
4492        }
4493    }
4494}
4495
4496#[cfg(not(windows))]
4497fn replace_file(source: &Path, target: &Path) -> Result<()> {
4498    fs::rename(source, target)
4499        .with_context(|| format!("failed to replace {} with {}", target.display(), source.display()))
4500}
4501
4502fn rest_information_cache_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
4503    source_state_dir(app_root, source).join("rest-information.json")
4504}
4505
4506fn rest_manifest_cache_path_with_root(
4507    app_root: &Path,
4508    source: &SourceRecord,
4509    package_id: &str,
4510    version: &str,
4511    channel: &str,
4512) -> PathBuf {
4513    let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel);
4514    let digest = sha256_hex(key.as_bytes());
4515    cache_root(app_root)
4516        .join("REST_M")
4517        .join(sanitize_path_segment(&source.identifier))
4518        .join(format!("{digest}.json"))
4519}
4520
4521fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
4522    cache_root(app_root)
4523        .join(bucket)
4524        .join(sanitize_path_segment(identifier))
4525}
4526
4527fn load_store(app_root: &Path) -> Result<SourceStore> {
4528    if uses_packaged_layout(app_root)
4529        && let Some(store) = load_packaged_store(app_root)?
4530    {
4531        return Ok(store);
4532    }
4533
4534    let path = store_path(app_root);
4535    if !path.exists() {
4536        let store = SourceStore::default();
4537        save_store(app_root, &store)?;
4538        return Ok(store);
4539    }
4540
4541    let bytes = fs::read(path).context("failed to read source store")?;
4542    serde_json::from_slice(&bytes).context("failed to parse source store")
4543}
4544
4545fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
4546    if uses_system_winget_source_commands(app_root) {
4547        bail!("System WinGet source settings must be modified through winget.");
4548    }
4549
4550    if uses_packaged_layout(app_root) {
4551        return save_packaged_store(app_root, store);
4552    }
4553
4554    write_json(store_path(app_root), store)
4555}
4556
4557fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
4558    let user_sources_path = packaged_user_sources_path(app_root)?;
4559    let metadata_path = app_root.join("sources_metadata");
4560    let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
4561    let metadata_yaml = fs::read_to_string(&metadata_path).ok();
4562    load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
4563}
4564
4565fn load_packaged_store_from_streams(
4566    user_sources_yaml: Option<&str>,
4567    metadata_yaml: Option<&str>,
4568) -> Result<Option<SourceStore>> {
4569    if is_secure_settings_stub(user_sources_yaml) {
4570        return load_system_winget_source_store().map(Some);
4571    }
4572
4573    Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
4574}
4575
4576fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
4577    let user_sources_path = packaged_user_sources_path(app_root)?;
4578    if let Some(parent) = user_sources_path.parent() {
4579        fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
4580    }
4581
4582    fs::write(&user_sources_path, render_packaged_sources_yaml(store))
4583        .context("failed to write packaged user_sources stream")?;
4584    fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
4585        .context("failed to write packaged source metadata")?;
4586    Ok(())
4587}
4588
4589fn uses_system_winget_source_commands(app_root: &Path) -> bool {
4590    if !uses_packaged_layout(app_root) {
4591        return false;
4592    }
4593
4594    #[cfg(windows)]
4595    {
4596        let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
4597            return false;
4598        };
4599
4600        let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
4601        is_secure_settings_stub(user_sources_yaml.as_deref())
4602    }
4603
4604    #[cfg(not(windows))]
4605    {
4606        false
4607    }
4608}
4609
4610fn is_secure_settings_stub(value: Option<&str>) -> bool {
4611    value
4612        .map(str::trim_start)
4613        .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
4614}
4615
4616fn load_system_winget_source_store() -> Result<SourceStore> {
4617    let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
4618    Ok(SourceStore {
4619        sources: parse_system_winget_source_export(&result.stdout)?,
4620    })
4621}
4622
4623fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
4624    run_checked_winget_source_command(
4625        build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
4626        &format!("add WinGet source '{name}'"),
4627    )?;
4628    Ok(())
4629}
4630
4631fn system_winget_remove_source(name: &str) -> Result<()> {
4632    run_checked_winget_source_command(
4633        vec![
4634            "source".to_owned(),
4635            "remove".to_owned(),
4636            "--name".to_owned(),
4637            name.to_owned(),
4638            "--disable-interactivity".to_owned(),
4639        ],
4640        &format!("remove WinGet source '{name}'"),
4641    )?;
4642    Ok(())
4643}
4644
4645fn system_winget_reset_source(name: &str) -> Result<()> {
4646    run_checked_winget_source_command(
4647        vec![
4648            "source".to_owned(),
4649            "reset".to_owned(),
4650            "--name".to_owned(),
4651            name.to_owned(),
4652            "--force".to_owned(),
4653            "--disable-interactivity".to_owned(),
4654        ],
4655        &format!("reset WinGet source '{name}'"),
4656    )?;
4657    Ok(())
4658}
4659
4660fn system_winget_reset_sources() -> Result<()> {
4661    run_checked_winget_source_command(
4662        vec![
4663            "source".to_owned(),
4664            "reset".to_owned(),
4665            "--force".to_owned(),
4666            "--disable-interactivity".to_owned(),
4667        ],
4668        "reset WinGet sources",
4669    )?;
4670    Ok(())
4671}
4672
4673fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
4674    let mut args = vec!["source".to_owned(), "update".to_owned()];
4675    if let Some(name) = name {
4676        args.push("--name".to_owned());
4677        args.push(name.to_owned());
4678    }
4679
4680    args.push("--disable-interactivity".to_owned());
4681    let result = run_checked_winget_source_command(
4682        args,
4683        if name.is_some() {
4684            "update WinGet source"
4685        } else {
4686            "update WinGet sources"
4687        },
4688    )?;
4689    let detail = result.stdout.trim();
4690    Ok(if detail.is_empty() {
4691        "Done".to_owned()
4692    } else {
4693        detail.to_owned()
4694    })
4695}
4696
4697fn build_winget_source_export_arguments() -> Vec<String> {
4698    vec![
4699        "source".to_owned(),
4700        "export".to_owned(),
4701        "--disable-interactivity".to_owned(),
4702    ]
4703}
4704
4705fn build_winget_source_add_arguments(
4706    name: &str,
4707    arg: &str,
4708    kind: SourceKind,
4709    trust_level: &str,
4710    explicit: bool,
4711) -> Vec<String> {
4712    let mut args = vec![
4713        "source".to_owned(),
4714        "add".to_owned(),
4715        "--name".to_owned(),
4716        name.to_owned(),
4717        "--arg".to_owned(),
4718        arg.to_owned(),
4719        "--type".to_owned(),
4720        packaged_source_type(kind).to_owned(),
4721        "--disable-interactivity".to_owned(),
4722    ];
4723
4724    if !trust_level.trim().is_empty() {
4725        args.push("--trust-level".to_owned());
4726        args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
4727            "trusted".to_owned()
4728        } else {
4729            "none".to_owned()
4730        });
4731    }
4732
4733    if explicit {
4734        args.push("--explicit".to_owned());
4735    }
4736
4737    args
4738}
4739
4740#[derive(Debug)]
4741struct WingetSourceCommandResult {
4742    exit_code: i32,
4743    stdout: String,
4744    stderr: String,
4745}
4746
4747type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
4748
4749static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
4750    RwLock::new(run_winget_source_command_process);
4751
4752fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
4753    let result = run_winget_source_command(&args)?;
4754    if result.exit_code == 0 {
4755        return Ok(result);
4756    }
4757
4758    let detail = [result.stderr.trim(), result.stdout.trim()]
4759        .into_iter()
4760        .filter(|value| !value.is_empty())
4761        .collect::<Vec<_>>()
4762        .join("\n");
4763    if detail.is_empty() {
4764        bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
4765    }
4766
4767    bail!(
4768        "Failed to {action}; winget exited with code {}:\n{}",
4769        result.exit_code,
4770        detail
4771    );
4772}
4773
4774fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
4775    let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
4776        .read()
4777        .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
4778    runner(args)
4779}
4780
4781fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
4782    use std::process::Command;
4783
4784    let output = Command::new("winget")
4785        .args(args)
4786        .output()
4787        .context("failed to run winget source command")?;
4788
4789    Ok(WingetSourceCommandResult {
4790        exit_code: output.status.code().unwrap_or(-1),
4791        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
4792        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
4793    })
4794}
4795
4796fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
4797    let trimmed = output.trim();
4798    if trimmed.is_empty() {
4799        return Ok(Vec::new());
4800    }
4801
4802    if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
4803        && let Some(sources) = parse_system_winget_source_export_value(&value)?
4804    {
4805        return Ok(order_sources(sources));
4806    }
4807
4808    let mut sources = Vec::new();
4809    for line in output.replace("\r\n", "\n").lines().map(str::trim) {
4810        if line.is_empty() {
4811            continue;
4812        }
4813
4814        let value: JsonValue = serde_json::from_str(line)
4815            .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
4816        let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
4817            bail!("WinGet source export returned a non-source line: {line}");
4818        };
4819        sources.append(&mut parsed);
4820    }
4821
4822    Ok(order_sources(sources))
4823}
4824
4825fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
4826    match value {
4827        JsonValue::Array(items) => items
4828            .iter()
4829            .map(parse_system_winget_source_record)
4830            .collect::<Result<Vec<_>>>()
4831            .map(Some),
4832        JsonValue::Object(object) => {
4833            if let Some(JsonValue::Array(items)) = object.get("Sources") {
4834                items
4835                    .iter()
4836                    .map(parse_system_winget_source_record)
4837                    .collect::<Result<Vec<_>>>()
4838                    .map(Some)
4839            } else if object.contains_key("Name") {
4840                Ok(Some(vec![parse_system_winget_source_record(value)?]))
4841            } else {
4842                Ok(None)
4843            }
4844        }
4845        _ => Ok(None),
4846    }
4847}
4848
4849fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
4850    let object = value
4851        .as_object()
4852        .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
4853    let name = required_json_string(object, "Name")?;
4854    let source_type = required_json_string(object, "Type")?;
4855    let arg = required_json_string(object, "Arg")?;
4856    let kind = parse_system_winget_source_kind(&source_type)?;
4857    let identifier = optional_json_string(object, "Identifier")
4858        .or_else(|| optional_json_string(object, "Data"))
4859        .unwrap_or_else(|| name.clone());
4860
4861    Ok(SourceRecord {
4862        name,
4863        kind,
4864        arg,
4865        identifier,
4866        trust_level: parse_system_winget_trust_level(object),
4867        explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
4868        priority: optional_json_i32(object, "Priority").unwrap_or_default(),
4869        last_update: None,
4870        source_version: None,
4871    })
4872}
4873
4874fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
4875    optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
4876}
4877
4878fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
4879    match object.get(key)? {
4880        JsonValue::String(value) => Some(value.clone()),
4881        JsonValue::Number(value) => Some(value.to_string()),
4882        _ => None,
4883    }
4884}
4885
4886fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
4887    match object.get(key)? {
4888        JsonValue::Bool(value) => Some(*value),
4889        JsonValue::String(value) => value.parse().ok(),
4890        _ => None,
4891    }
4892}
4893
4894fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
4895    match object.get(key)? {
4896        JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
4897        JsonValue::String(value) => value.parse().ok(),
4898        _ => None,
4899    }
4900}
4901
4902fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
4903    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
4904        Ok(SourceKind::PreIndexed)
4905    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
4906        Ok(SourceKind::Rest)
4907    } else {
4908        bail!("Unsupported WinGet source type '{value}'.")
4909    }
4910}
4911
4912fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
4913    let Some(value) = object.get("TrustLevel") else {
4914        return "None".to_owned();
4915    };
4916
4917    match value {
4918        JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
4919        JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
4920        JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
4921        _ => "None".to_owned(),
4922    }
4923}
4924
4925fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
4926    value
4927        .as_str()
4928        .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
4929}
4930
4931fn order_sources(mut sources: Vec<SourceRecord>) -> Vec<SourceRecord> {
4932    sources.sort_by(|left, right| {
4933        left.explicit
4934            .cmp(&right.explicit)
4935            .then_with(|| right.priority.cmp(&left.priority))
4936            .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
4937    });
4938    sources
4939}
4940
4941fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
4942    if user_sources_yaml.is_none() && metadata_yaml.is_none() {
4943        return None;
4944    }
4945
4946    let mut sources = SourceStore::default().sources;
4947    for entry in parse_packaged_yaml_entries(user_sources_yaml) {
4948        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
4949            continue;
4950        };
4951
4952        if yaml_bool(entry.get("IsTombstone")) {
4953            sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
4954            continue;
4955        }
4956
4957        let Some(mapped) = map_packaged_source_entry(&entry) else {
4958            continue;
4959        };
4960
4961        if let Some(existing) = sources
4962            .iter_mut()
4963            .find(|source| source.name.eq_ignore_ascii_case(&name))
4964        {
4965            *existing = mapped;
4966        } else {
4967            sources.push(mapped);
4968        }
4969    }
4970
4971    for entry in parse_packaged_yaml_entries(metadata_yaml) {
4972        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
4973            continue;
4974        };
4975
4976        let Some(source) = sources
4977            .iter_mut()
4978            .find(|source| source.name.eq_ignore_ascii_case(&name))
4979        else {
4980            continue;
4981        };
4982
4983        if let Some(last_update) = entry
4984            .get("LastUpdate")
4985            .and_then(|value| value.as_deref())
4986            .and_then(parse_packaged_datetime)
4987        {
4988            source.last_update = Some(last_update);
4989        }
4990
4991        if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
4992            && !source_version.is_empty()
4993        {
4994            source.source_version = Some(source_version);
4995        }
4996    }
4997
4998    Some(SourceStore { sources })
4999}
5000
5001fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
5002    let Some(yaml) = yaml else {
5003        return Vec::new();
5004    };
5005
5006    let mut entries = Vec::new();
5007    let mut current: Option<BTreeMap<String, Option<String>>> = None;
5008    for raw_line in yaml.replace("\r\n", "\n").split('\n') {
5009        let line = raw_line.trim_end();
5010        let trimmed = line.trim();
5011        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
5012            continue;
5013        }
5014
5015        if let Some(remainder) = trimmed.strip_prefix("- ") {
5016            if let Some(entry) = current.take() {
5017                entries.push(entry);
5018            }
5019
5020            let mut entry = BTreeMap::new();
5021            add_packaged_yaml_key_value(&mut entry, remainder);
5022            current = Some(entry);
5023            continue;
5024        }
5025
5026        if let Some(entry) = current.as_mut() {
5027            add_packaged_yaml_key_value(entry, trimmed);
5028        }
5029    }
5030
5031    if let Some(entry) = current {
5032        entries.push(entry);
5033    }
5034
5035    entries
5036}
5037
5038fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
5039    let Some((key, value)) = line.split_once(':') else {
5040        return;
5041    };
5042
5043    entry.insert(
5044        key.trim().to_owned(),
5045        Some(unquote_yaml_scalar(value.trim()).to_owned()),
5046    );
5047}
5048
5049fn unquote_yaml_scalar(value: &str) -> &str {
5050    if value.len() >= 2 {
5051        let bytes = value.as_bytes();
5052        let first = bytes[0] as char;
5053        let last = bytes[value.len() - 1] as char;
5054        if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
5055            return &value[1..value.len() - 1];
5056        }
5057    }
5058
5059    value
5060}
5061
5062fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
5063    let name = entry.get("Name").and_then(|value| value.clone())?;
5064    let source_type = entry.get("Type").and_then(|value| value.clone())?;
5065    let arg = entry.get("Arg").and_then(|value| value.clone())?;
5066    let kind = parse_packaged_source_kind(&source_type)?;
5067
5068    Some(SourceRecord {
5069        name: name.clone(),
5070        kind,
5071        arg,
5072        identifier: entry
5073            .get("Identifier")
5074            .and_then(|value| value.clone())
5075            .or_else(|| entry.get("Data").and_then(|value| value.clone()))
5076            .unwrap_or(name),
5077        trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
5078        explicit: yaml_bool(entry.get("Explicit")),
5079        priority: entry
5080            .get("Priority")
5081            .and_then(|value| value.as_deref())
5082            .and_then(|value| value.parse::<i32>().ok())
5083            .unwrap_or_default(),
5084        last_update: None,
5085        source_version: None,
5086    })
5087}
5088
5089fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
5090    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
5091        Some(SourceKind::PreIndexed)
5092    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
5093        Some(SourceKind::Rest)
5094    } else {
5095        None
5096    }
5097}
5098
5099fn parse_packaged_trust_level(value: Option<&str>) -> String {
5100    match value {
5101        Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
5102        Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
5103        _ => "None".to_owned(),
5104    }
5105}
5106
5107fn yaml_bool(value: Option<&Option<String>>) -> bool {
5108    value
5109        .and_then(|raw| raw.as_deref())
5110        .and_then(|raw| raw.parse::<bool>().ok())
5111        .unwrap_or(false)
5112}
5113
5114fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
5115    if let Ok(unix_seconds) = value.parse::<i64>() {
5116        return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
5117    }
5118
5119    DateTime::parse_from_rfc3339(value)
5120        .ok()
5121        .map(|value| value.with_timezone(&Utc))
5122}
5123
5124fn render_packaged_sources_yaml(store: &SourceStore) -> String {
5125    let mut lines = vec!["Sources:".to_owned()];
5126    for source in &store.sources {
5127        lines.push(format!("  - Name: {}", source.name));
5128        lines.push(format!("    Type: {}", packaged_source_type(source.kind)));
5129        lines.push(format!("    Arg: {}", yaml_scalar(&source.arg)));
5130        lines.push(format!("    Data: {}", yaml_scalar(&source.identifier)));
5131        lines.push(format!(
5132            "    TrustLevel: {}",
5133            if source.trust_level.eq_ignore_ascii_case("Trusted") {
5134                1
5135            } else {
5136                0
5137            }
5138        ));
5139        lines.push(format!("    Explicit: {}", source.explicit));
5140        lines.push(format!("    Priority: {}", source.priority));
5141        lines.push("    IsTombstone: false".to_owned());
5142    }
5143    lines.push(String::new());
5144    lines.join("\n")
5145}
5146
5147fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
5148    let mut lines = vec!["Sources:".to_owned()];
5149    for source in &store.sources {
5150        if source.last_update.is_none() && source.source_version.is_none() {
5151            continue;
5152        }
5153
5154        lines.push(format!("  - Name: {}", source.name));
5155        if let Some(last_update) = source.last_update {
5156            lines.push(format!("    LastUpdate: {}", last_update.timestamp()));
5157        }
5158        if let Some(source_version) = &source.source_version {
5159            lines.push(format!("    SourceVersion: {}", yaml_scalar(source_version)));
5160        }
5161    }
5162    lines.push(String::new());
5163    lines.join("\n")
5164}
5165
5166fn yaml_scalar(value: &str) -> String {
5167    if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
5168        format!("\"{}\"", value.replace('"', "\\\""))
5169    } else {
5170        value.to_owned()
5171    }
5172}
5173
5174fn uses_packaged_layout(app_root: &Path) -> bool {
5175    #[cfg(windows)]
5176    {
5177        try_get_packaged_local_state_root(app_root).is_some()
5178    }
5179
5180    #[cfg(not(windows))]
5181    {
5182        let _ = app_root;
5183        false
5184    }
5185}
5186
5187#[cfg(windows)]
5188fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
5189    let local_state = app_root.file_name()?.to_string_lossy();
5190    if !local_state.eq_ignore_ascii_case("LocalState") {
5191        return None;
5192    }
5193
5194    let family = app_root.parent()?.file_name()?.to_string_lossy();
5195    if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
5196        return None;
5197    }
5198
5199    let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
5200    if !packages.eq_ignore_ascii_case("Packages") {
5201        return None;
5202    }
5203
5204    Some(app_root.to_path_buf())
5205}
5206
5207#[cfg(windows)]
5208fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
5209    let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
5210    Ok(packaged_secure_settings_root()?.join("user_sources"))
5211}
5212
5213#[cfg(not(windows))]
5214fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
5215    bail!("packaged WinGet source streams are only available on Windows")
5216}
5217
5218#[cfg(windows)]
5219fn packaged_secure_settings_root() -> Result<PathBuf> {
5220    let program_data = std::env::var_os("PROGRAMDATA")
5221        .map(PathBuf::from)
5222        .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
5223    Ok(program_data
5224        .join("Microsoft")
5225        .join("WinGet")
5226        .join(current_user_sid()?)
5227        .join("settings")
5228        .join("pkg")
5229        .join(PACKAGED_NAME))
5230}
5231
5232#[cfg(windows)]
5233#[allow(clippy::multiple_unsafe_ops_per_block)]
5234fn current_user_sid() -> Result<String> {
5235    fn close_token(token: HANDLE) {
5236        if !token.is_null() {
5237            // SAFETY: token is a HANDLE returned by OpenProcessToken and may be closed once here.
5238            unsafe {
5239                CloseHandle(token);
5240            }
5241        }
5242    }
5243
5244    let mut token: HANDLE = std::ptr::null_mut();
5245    // SAFETY: GetCurrentProcess returns a pseudo-handle valid for OpenProcessToken in this process.
5246    let current_process = unsafe { GetCurrentProcess() };
5247    // SAFETY: token points to valid storage for the process token handle on success.
5248    if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
5249        bail!("failed to open current process token")
5250    }
5251
5252    let mut length = 0;
5253    // SAFETY: The null buffer call asks Windows for the required buffer length.
5254    unsafe {
5255        GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
5256    }
5257
5258    let element_size = size_of::<TOKEN_USER>();
5259    let element_count = (length as usize).div_ceil(element_size);
5260    let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
5261    // SAFETY: buffer is aligned for TOKEN_USER and has at least length bytes of storage.
5262    if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
5263        close_token(token);
5264        bail!("failed to get current user token information")
5265    }
5266
5267    // SAFETY: GetTokenInformation initialized the buffer as a TOKEN_USER structure.
5268    let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
5269    let mut sid_ptr: *mut u16 = std::ptr::null_mut();
5270    // SAFETY: token_user.User.Sid is supplied by Windows and sid_ptr receives a LocalAlloc buffer on success.
5271    if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
5272        close_token(token);
5273        bail!("failed to convert current user SID")
5274    }
5275
5276    let mut len = 0usize;
5277    // SAFETY: sid_ptr is a null-terminated UTF-16 string returned by ConvertSidToStringSidW.
5278    while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
5279        len += 1;
5280    }
5281
5282    // SAFETY: sid_ptr points to len initialized UTF-16 code units.
5283    let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
5284    let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned();
5285    // SAFETY: sid_ptr was allocated by ConvertSidToStringSidW and must be released with LocalFree.
5286    unsafe {
5287        LocalFree(sid_ptr.cast());
5288    }
5289    close_token(token);
5290    Ok(sid)
5291}
5292
5293fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
5294    app_root.join("Microsoft").join("Windows Package Manager")
5295}
5296
5297fn cache_root(app_root: &Path) -> PathBuf {
5298    if uses_packaged_layout(app_root) {
5299        packaged_file_cache_root(app_root)
5300    } else {
5301        default_cache_root_fallback()
5302    }
5303}
5304
5305fn default_cache_root_fallback() -> PathBuf {
5306    std::env::temp_dir().join("cache")
5307}
5308
5309fn packaged_source_type(kind: SourceKind) -> &'static str {
5310    match kind {
5311        SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
5312        SourceKind::Rest => "Microsoft.Rest",
5313    }
5314}
5315
5316fn sanitize_path_segment(value: &str) -> String {
5317    value
5318        .chars()
5319        .map(|ch| match ch {
5320            '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
5321            _ => ch,
5322        })
5323        .collect()
5324}
5325
5326fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
5327    if !path.exists() {
5328        return Ok(JsonMap::new());
5329    }
5330
5331    let bytes = fs::read(path).context("failed to read JSON file")?;
5332    let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
5333    Ok(match value {
5334        JsonValue::Object(object) => object,
5335        _ => JsonMap::new(),
5336    })
5337}
5338
5339fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
5340    write_json(path, value)
5341}
5342
5343fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
5344    if let Some(parent) = path.parent() {
5345        fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
5346    }
5347
5348    let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
5349    fs::write(path, bytes).context("failed to write JSON file")
5350}
5351
5352fn merge_json_objects(
5353    current: &JsonMap<String, JsonValue>,
5354    update: &JsonMap<String, JsonValue>,
5355) -> JsonMap<String, JsonValue> {
5356    let mut merged = current.clone();
5357    for (key, value) in update {
5358        match (merged.get(key), value) {
5359            (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
5360                merged.insert(
5361                    key.clone(),
5362                    JsonValue::Object(merge_json_objects(current_object, update_object)),
5363                );
5364            }
5365            _ => {
5366                merged.insert(key.clone(), value.clone());
5367            }
5368        }
5369    }
5370
5371    merged
5372}
5373
5374fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
5375    match expected {
5376        JsonValue::Object(expected_object) => match current {
5377            JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
5378                current_object
5379                    .get(key)
5380                    .is_some_and(|current_child| json_contains(current_child, expected_child))
5381            }),
5382            _ => false,
5383        },
5384        JsonValue::Array(expected_array) => match current {
5385            JsonValue::Array(current_array) => {
5386                current_array.len() == expected_array.len()
5387                    && current_array
5388                        .iter()
5389                        .zip(expected_array.iter())
5390                        .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
5391            }
5392            _ => false,
5393        },
5394        _ => current == expected,
5395    }
5396}
5397
5398fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
5399    match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
5400        "none" | "default" => Ok("None".to_owned()),
5401        "trusted" => Ok("Trusted".to_owned()),
5402        other => bail!("unsupported source trust level: {other}"),
5403    }
5404}
5405
5406fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
5407    SUPPORTED_ADMIN_SETTINGS
5408        .iter()
5409        .copied()
5410        .find(|setting| setting.eq_ignore_ascii_case(name))
5411        .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
5412}
5413
5414fn default_source_trust_level() -> String {
5415    "None".to_owned()
5416}
5417
5418fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
5419where
5420    F: FnMut(&SqlRow) -> Result<T>,
5421{
5422    let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
5423        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
5424    let mut result = Vec::new();
5425
5426    while let Some(row) = pollster::block_on(rows.next()).context("failed to read SQL row")? {
5427        result.push(map(&row)?);
5428    }
5429
5430    Ok(result)
5431}
5432
5433fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
5434where
5435    F: FnOnce(&SqlRow) -> Result<T>,
5436{
5437    let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
5438        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
5439
5440    match pollster::block_on(rows.next()).context("failed to read SQL row")? {
5441        Some(row) => Ok(Some(map(&row)?)),
5442        None => Ok(None),
5443    }
5444}
5445
5446fn execute_sql(connection: &Connection, sql: &str, params: Vec<SqlValue>) -> Result<u64> {
5447    pollster::block_on(connection.execute(sql, params_from_iter(params)))
5448        .with_context(|| format!("failed to execute SQL statement: {sql}"))
5449}
5450
5451fn execute_batch_sql(connection: &Connection, sql: &str) -> Result<()> {
5452    pollster::block_on(connection.execute_batch(sql)).with_context(|| format!("failed to execute SQL batch: {sql}"))
5453}
5454
5455fn row_value(row: &SqlRow, index: usize) -> Result<SqlValue> {
5456    row.get_value(index)
5457        .with_context(|| format!("failed to read SQL column {index}"))
5458}
5459
5460fn row_string(row: &SqlRow, index: usize) -> Result<String> {
5461    match row_value(row, index)? {
5462        SqlValue::Text(value) => Ok(value),
5463        SqlValue::Integer(value) => Ok(value.to_string()),
5464        SqlValue::Real(value) => Ok(value.to_string()),
5465        SqlValue::Blob(value) => String::from_utf8(value).context("SQL blob column was not valid UTF-8"),
5466        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
5467    }
5468}
5469
5470fn row_opt_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
5471    match row_value(row, index)? {
5472        SqlValue::Null => Ok(None),
5473        SqlValue::Text(value) => Ok(Some(value)),
5474        SqlValue::Integer(value) => Ok(Some(value.to_string())),
5475        SqlValue::Real(value) => Ok(Some(value.to_string())),
5476        SqlValue::Blob(value) => Ok(Some(
5477            String::from_utf8(value).context("SQL blob column was not valid UTF-8")?,
5478        )),
5479    }
5480}
5481
5482fn row_i64(row: &SqlRow, index: usize) -> Result<i64> {
5483    match row_value(row, index)? {
5484        SqlValue::Integer(value) => Ok(value),
5485        SqlValue::Text(value) => value
5486            .parse::<i64>()
5487            .with_context(|| format!("failed to parse integer from SQL text column {index}")),
5488        SqlValue::Real(value) => f64_to_i64_checked(value, index),
5489        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
5490        SqlValue::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
5491    }
5492}
5493
5494fn row_hex_string(row: &SqlRow, index: usize) -> Result<String> {
5495    match row_value(row, index)? {
5496        SqlValue::Blob(value) => Ok(bytes_to_hex(&value)),
5497        SqlValue::Text(value) => Ok(value.to_ascii_lowercase()),
5498        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
5499        SqlValue::Integer(value) => Ok(format!("{value:x}")),
5500        SqlValue::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
5501    }
5502}
5503
5504fn row_opt_hex_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
5505    match row_value(row, index)? {
5506        SqlValue::Null => Ok(None),
5507        SqlValue::Blob(value) => Ok(Some(bytes_to_hex(&value))),
5508        SqlValue::Text(value) => Ok(Some(value.to_ascii_lowercase())),
5509        SqlValue::Integer(value) => Ok(Some(format!("{value:x}"))),
5510        SqlValue::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
5511    }
5512}
5513
5514fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
5515    if !value.is_finite() {
5516        bail!("SQL column {index} contained a non-finite floating-point value");
5517    }
5518
5519    if value.fract() != 0.0 {
5520        bail!("SQL column {index} contained a non-integer floating-point value");
5521    }
5522
5523    if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
5524        bail!("SQL column {index} contained a floating-point value outside the i64 range");
5525    }
5526
5527    format!("{value:.0}")
5528        .parse::<i64>()
5529        .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
5530}
5531
5532fn bytes_to_hex(bytes: &[u8]) -> String {
5533    let mut result = String::with_capacity(bytes.len() * 2);
5534    for byte in bytes {
5535        result.push_str(&format!("{byte:02x}"));
5536    }
5537    result
5538}
5539
5540fn query_v2_matches(
5541    connection: &Connection,
5542    query: &PackageQuery,
5543    semantics: SearchSemantics,
5544) -> Result<(Vec<V2SearchRow>, bool)> {
5545    let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
5546    let limit = source_fetch_results(query, semantics);
5547    let sql = format!(
5548        "SELECT rowid, id, name, moniker, latest_version, hash \
5549         FROM packages WHERE {where_clause} LIMIT {}",
5550        limit + 1
5551    );
5552    let mut rows = query_rows(
5553        connection,
5554        &sql,
5555        params.into_iter().map(SqlValue::Text).collect(),
5556        |row| {
5557            Ok(V2SearchRow {
5558                package_rowid: row_i64(row, 0)?,
5559                id: row_string(row, 1)?,
5560                name: row_string(row, 2)?,
5561                moniker: row_opt_string(row, 3)?,
5562                version: row_string(row, 4)?,
5563                package_hash: row_hex_string(row, 5)?,
5564                match_criteria: None,
5565            })
5566        },
5567    )?;
5568    for row in &mut rows {
5569        row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
5570    }
5571    let truncated = rows.len() > limit;
5572    rows.truncate(limit);
5573    Ok((rows, truncated))
5574}
5575
5576fn query_v1_matches(
5577    connection: &Connection,
5578    query: &PackageQuery,
5579    semantics: SearchSemantics,
5580) -> Result<(Vec<V1SearchRow>, bool)> {
5581    let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
5582    let limit = source_fetch_results(query, semantics);
5583    let sql = format!(
5584        "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
5585         FROM manifest \
5586         JOIN ids ON manifest.id = ids.rowid \
5587         JOIN names ON manifest.name = names.rowid \
5588         LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
5589         JOIN versions ON manifest.version = versions.rowid \
5590         JOIN channels ON manifest.channel = channels.rowid \
5591         WHERE {where_clause} LIMIT {}",
5592        limit + 1
5593    );
5594    let mut rows = query_rows(
5595        connection,
5596        &sql,
5597        params.into_iter().map(SqlValue::Text).collect(),
5598        |row| {
5599            Ok(V1SearchRow {
5600                manifest_rowid: row_i64(row, 0)?,
5601                package_rowid: row_i64(row, 1)?,
5602                version: row_string(row, 2)?,
5603                channel: row_string(row, 3)?,
5604                id: row_string(row, 4)?,
5605                name: row_string(row, 5)?,
5606                moniker: row_opt_string(row, 6)?,
5607                match_criteria: None,
5608            })
5609        },
5610    )?;
5611    for row in &mut rows {
5612        row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
5613    }
5614    let truncated = rows.len() > limit;
5615    rows.truncate(limit);
5616    Ok((rows, truncated))
5617}
5618
5619fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
5620    let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
5621
5622    for row in rows {
5623        match grouped.get(&row.package_rowid) {
5624            Some(existing)
5625                if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
5626                    != Ordering::Less => {}
5627            _ => {
5628                grouped.insert(row.package_rowid, row);
5629            }
5630        }
5631    }
5632
5633    grouped.into_values().collect()
5634}
5635
5636fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
5637    let has_hash = table_has_column(connection, "manifest", "hash")?;
5638    let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
5639    let sql = format!(
5640        "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
5641         FROM manifest \
5642         JOIN versions ON manifest.version = versions.rowid \
5643         JOIN channels ON manifest.channel = channels.rowid \
5644         WHERE manifest.id = ?1"
5645    );
5646    query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
5647        Ok(V1VersionRow {
5648            version: row_string(row, 0)?,
5649            channel: row_string(row, 1)?,
5650            pathpart_id: row_i64(row, 2)?,
5651            manifest_hash: row_opt_hex_string(row, 3)?,
5652        })
5653    })
5654}
5655
5656fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
5657    let mut parts = Vec::new();
5658    let mut current = Some(pathpart_id);
5659
5660    while let Some(id) = current {
5661        let (parent, pathpart) = query_optional_value(
5662            connection,
5663            "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
5664            vec![SqlValue::Integer(id)],
5665            |row| {
5666                Ok((
5667                    row_opt_string(row, 0)?
5668                        .and_then(|value| value.parse::<i64>().ok())
5669                        .filter(|value| *value != 0),
5670                    row_string(row, 1)?,
5671                ))
5672            },
5673        )?
5674        .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
5675        parts.push(pathpart);
5676        current = parent;
5677    }
5678
5679    parts.reverse();
5680    Ok(parts.join("/"))
5681}
5682
5683fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
5684    format!(
5685        "packages/{}/{}/versionData.mszyml",
5686        package_id,
5687        package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
5688    )
5689}
5690
5691fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
5692    let message = format!("{error:#}").to_ascii_lowercase();
5693    message.contains("no such table") && message.contains("packages")
5694}
5695
5696fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
5697    for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
5698        row_string(row, 1)
5699    })? {
5700        if value.eq_ignore_ascii_case(column) {
5701            return Ok(true);
5702        }
5703    }
5704
5705    Ok(false)
5706}
5707
5708fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
5709    let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
5710    let id_column = if v2 { "id" } else { "ids.id" };
5711    let name_column = if v2 { "name" } else { "names.name" };
5712    let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
5713    let exact_match = query.exact || semantics == SearchSemantics::Single;
5714    let mut params = Vec::new();
5715
5716    if let Some(value) = &query.id {
5717        return (
5718            single_field_condition(id_column, value, exact_match, &mut params),
5719            params,
5720        );
5721    }
5722    if let Some(value) = &query.name {
5723        return (
5724            single_field_condition(name_column, value, exact_match, &mut params),
5725            params,
5726        );
5727    }
5728    if let Some(value) = &query.moniker {
5729        return (
5730            single_field_condition(moniker_column, value, exact_match, &mut params),
5731            params,
5732        );
5733    }
5734    if let Some(value) = &query.tag {
5735        return (
5736            mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
5737            params,
5738        );
5739    }
5740    if let Some(value) = &query.command {
5741        return (
5742            mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
5743            params,
5744        );
5745    }
5746    if let Some(value) = &query.query {
5747        if exact_match {
5748            let conditions = [
5749                single_field_condition(id_column, value, true, &mut params),
5750                single_field_condition(name_column, value, true, &mut params),
5751                single_field_condition(moniker_column, value, true, &mut params),
5752            ];
5753            return (format!("({})", conditions.join(" OR ")), params);
5754        }
5755
5756        let mut conditions = vec![
5757            single_field_condition(id_column, value, false, &mut params),
5758            single_field_condition(name_column, value, false, &mut params),
5759            single_field_condition(moniker_column, value, false, &mut params),
5760        ];
5761        if semantics == SearchSemantics::Many {
5762            conditions.push(mapped_field_condition(
5763                v2,
5764                "tag",
5765                value,
5766                rowid_column,
5767                false,
5768                &mut params,
5769            ));
5770            conditions.push(mapped_field_condition(
5771                v2,
5772                "command",
5773                value,
5774                rowid_column,
5775                false,
5776                &mut params,
5777            ));
5778        }
5779        return (format!("({})", conditions.join(" OR ")), params);
5780    }
5781
5782    ("1 = 1".to_owned(), Vec::new())
5783}
5784
5785fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
5786    params.push(match_parameter(value, exact));
5787    format!("{column} LIKE ?{}", params.len())
5788}
5789
5790fn mapped_field_condition(
5791    v2: bool,
5792    value_name: &str,
5793    value: &str,
5794    rowid_column: &str,
5795    exact: bool,
5796    params: &mut Vec<String>,
5797) -> String {
5798    let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
5799        (
5800            format!("{value_name}s2"),
5801            format!("{value_name}s2_map"),
5802            value_name.to_owned(),
5803            "package".to_owned(),
5804        )
5805    } else {
5806        (
5807            format!("{value_name}s"),
5808            format!("{value_name}s_map"),
5809            value_name.to_owned(),
5810            "manifest".to_owned(),
5811        )
5812    };
5813    params.push(match_parameter(value, exact));
5814    let parameter = params.len();
5815    // Use a non-correlated `rowid IN (...)` subquery rather than a correlated
5816    // `EXISTS (... WHERE map.owner = packages.rowid ...)`. The correlated form
5817    // re-scans the (un-indexed) map table once per candidate package row, which
5818    // is quadratic: on the real winget index it takes ~19s under SQLite and
5819    // effectively hangs under the Turso engine. The `IN` form evaluates the
5820    // owner-id set a single time, collapsing the search back to milliseconds.
5821    format!(
5822        "{rowid_column} IN (SELECT {map_table_name}.{map_owner_column} \
5823         FROM {map_table_name} JOIN {table_name} ON \
5824         {map_table_name}.{value_name} = {table_name}.rowid \
5825         WHERE {table_name}.{map_value_column} LIKE ?{parameter})"
5826    )
5827}
5828
5829fn match_parameter(value: &str, exact: bool) -> String {
5830    if exact { value.to_owned() } else { format!("%{value}%") }
5831}
5832
5833fn build_rest_search_body(
5834    query: &PackageQuery,
5835    info: &RestInformation,
5836    semantics: SearchSemantics,
5837) -> Result<JsonValue> {
5838    let mut root = serde_json::Map::new();
5839    root.insert(
5840        "MaximumResults".to_owned(),
5841        JsonValue::from(source_fetch_results(query, semantics) as u64),
5842    );
5843    let exact_match = query.exact || semantics == SearchSemantics::Single;
5844
5845    if let Some(value) = &query.query {
5846        if semantics == SearchSemantics::Single {
5847            let mut filters = vec![
5848                rest_filter("PackageIdentifier", value, true),
5849                rest_filter("PackageName", value, true),
5850                rest_filter("Moniker", value, true),
5851            ];
5852            append_required_rest_filters(&mut filters, info);
5853            root.insert("Filters".to_owned(), JsonValue::Array(filters));
5854            return Ok(JsonValue::Object(root));
5855        }
5856
5857        root.insert(
5858            "Query".to_owned(),
5859            serde_json::json!({
5860                "KeyWord": value,
5861                "MatchType": if exact_match { "Exact" } else { "Substring" },
5862            }),
5863        );
5864    }
5865
5866    let mut filters = Vec::new();
5867    if let Some(value) = &query.id {
5868        filters.push(rest_filter("PackageIdentifier", value, exact_match));
5869    }
5870    if let Some(value) = &query.name {
5871        filters.push(rest_filter("PackageName", value, exact_match));
5872    }
5873    if let Some(value) = &query.moniker {
5874        filters.push(rest_filter("Moniker", value, exact_match));
5875    }
5876    if let Some(value) = &query.tag {
5877        filters.push(rest_filter("Tag", value, true));
5878    }
5879    if let Some(value) = &query.command {
5880        filters.push(rest_filter("Command", value, true));
5881    }
5882    append_required_rest_filters(&mut filters, info);
5883
5884    if !filters.is_empty() {
5885        root.insert("Filters".to_owned(), JsonValue::Array(filters));
5886    }
5887
5888    Ok(JsonValue::Object(root))
5889}
5890
5891fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
5892    if info
5893        .required_package_match_fields
5894        .iter()
5895        .any(|field| field.eq_ignore_ascii_case("Market"))
5896    {
5897        filters.push(rest_filter("Market", &default_market(), true));
5898    }
5899}
5900
5901fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
5902    serde_json::json!({
5903        "PackageMatchField": field,
5904        "RequestMatch": {
5905            "KeyWord": value,
5906            "MatchType": if exact { "Exact" } else { "Substring" },
5907        }
5908    })
5909}
5910
5911fn max_results(query: &PackageQuery) -> usize {
5912    query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
5913}
5914
5915fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
5916    match semantics {
5917        SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
5918        SearchSemantics::Single => max_results(query),
5919    }
5920}
5921
5922fn infer_preindexed_match_criteria_v2(
5923    connection: &Connection,
5924    row: &V2SearchRow,
5925    query: &PackageQuery,
5926    semantics: SearchSemantics,
5927) -> Result<Option<String>> {
5928    infer_match_criteria(
5929        &row.id,
5930        &row.name,
5931        row.moniker.as_deref(),
5932        query,
5933        semantics,
5934        |value| {
5935            find_mapped_value_v2(
5936                connection,
5937                "tags2",
5938                "tags2_map",
5939                "tag",
5940                row.package_rowid,
5941                value,
5942                query.exact,
5943            )
5944        },
5945        |value| {
5946            find_mapped_value_v2(
5947                connection,
5948                "commands2",
5949                "commands2_map",
5950                "command",
5951                row.package_rowid,
5952                value,
5953                query.exact,
5954            )
5955        },
5956    )
5957}
5958
5959fn infer_preindexed_match_criteria_v1(
5960    connection: &Connection,
5961    row: &V1SearchRow,
5962    query: &PackageQuery,
5963    semantics: SearchSemantics,
5964) -> Result<Option<String>> {
5965    infer_match_criteria(
5966        &row.id,
5967        &row.name,
5968        row.moniker.as_deref(),
5969        query,
5970        semantics,
5971        |value| {
5972            find_mapped_value_v1(
5973                connection,
5974                "tags",
5975                "tags_map",
5976                "tag",
5977                row.manifest_rowid,
5978                value,
5979                query.exact,
5980            )
5981        },
5982        |value| {
5983            find_mapped_value_v1(
5984                connection,
5985                "commands",
5986                "commands_map",
5987                "command",
5988                row.manifest_rowid,
5989                value,
5990                query.exact,
5991            )
5992        },
5993    )
5994}
5995
5996fn infer_match_criteria<FTag, FCommand>(
5997    id: &str,
5998    name: &str,
5999    moniker: Option<&str>,
6000    query: &PackageQuery,
6001    semantics: SearchSemantics,
6002    find_tag: FTag,
6003    find_command: FCommand,
6004) -> Result<Option<String>>
6005where
6006    FTag: FnOnce(&str) -> Result<Option<String>>,
6007    FCommand: FnOnce(&str) -> Result<Option<String>>,
6008{
6009    if let Some(value) = &query.tag {
6010        return Ok(Some(format_match_criteria("Tag", value)));
6011    }
6012    if let Some(value) = &query.command {
6013        return Ok(Some(format_match_criteria("Command", value)));
6014    }
6015    if let Some(value) = &query.moniker {
6016        return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
6017    }
6018    if semantics == SearchSemantics::Single {
6019        return Ok(None);
6020    }
6021    if let Some(value) = &query.query {
6022        if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
6023            return Ok(None);
6024        }
6025        if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
6026            return Ok(Some(format_match_criteria("Moniker", moniker_value)));
6027        }
6028        if let Some(tag) = find_tag(value)? {
6029            return Ok(Some(format_match_criteria("Tag", &tag)));
6030        }
6031        if let Some(command) = find_command(value)? {
6032            return Ok(Some(format_match_criteria("Command", &command)));
6033        }
6034    }
6035    Ok(None)
6036}
6037
6038fn find_mapped_value_v2(
6039    connection: &Connection,
6040    table_name: &str,
6041    map_table_name: &str,
6042    value_name: &str,
6043    package_rowid: i64,
6044    query: &str,
6045    exact: bool,
6046) -> Result<Option<String>> {
6047    let sql = format!(
6048        "SELECT {table_name}.{value_name} FROM {map_table_name} \
6049         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
6050         WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
6051    );
6052    let values = query_rows(
6053        connection,
6054        &sql,
6055        vec![
6056            SqlValue::Integer(package_rowid),
6057            SqlValue::Text(match_parameter(query, exact)),
6058        ],
6059        |row| row_string(row, 0),
6060    )?;
6061    Ok(select_best_text_match(values, query, exact))
6062}
6063
6064fn find_mapped_value_v1(
6065    connection: &Connection,
6066    table_name: &str,
6067    map_table_name: &str,
6068    value_name: &str,
6069    manifest_rowid: i64,
6070    query: &str,
6071    exact: bool,
6072) -> Result<Option<String>> {
6073    let sql = format!(
6074        "SELECT {table_name}.{value_name} FROM {map_table_name} \
6075         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
6076         WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
6077    );
6078    let values = query_rows(
6079        connection,
6080        &sql,
6081        vec![
6082            SqlValue::Integer(manifest_rowid),
6083            SqlValue::Text(match_parameter(query, exact)),
6084        ],
6085        |row| row_string(row, 0),
6086    )?;
6087    Ok(select_best_text_match(values, query, exact))
6088}
6089
6090fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
6091    if let Some(value) = &query.tag {
6092        return Some(format_match_criteria("Tag", value));
6093    }
6094    if let Some(value) = &query.command {
6095        return Some(format_match_criteria("Command", value));
6096    }
6097    if let Some(value) = &query.moniker {
6098        return Some(format_match_criteria("Moniker", value));
6099    }
6100    if semantics == SearchSemantics::Single {
6101        return None;
6102    }
6103    let value = query.query.as_deref()?;
6104    if json_string(item, "PackageIdentifier")
6105        .as_deref()
6106        .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6107        || json_string(item, "PackageName")
6108            .as_deref()
6109            .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6110    {
6111        return None;
6112    }
6113    json_string(item, "Moniker")
6114        .filter(|candidate| matches_text(candidate, value, query.exact))
6115        .map(|candidate| format_match_criteria("Moniker", &candidate))
6116}
6117
6118fn format_match_criteria(field: &str, value: &str) -> String {
6119    format!("{field}: {value}")
6120}
6121
6122fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
6123    if exact {
6124        candidate.eq_ignore_ascii_case(query)
6125    } else {
6126        candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
6127    }
6128}
6129
6130fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
6131    let mut score = 0;
6132
6133    if let Some(value) = &query.query {
6134        score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
6135        score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
6136        if let Some(moniker) = candidate.moniker.as_deref() {
6137            score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
6138        }
6139    }
6140
6141    if let Some(value) = &query.id {
6142        score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
6143    }
6144    if let Some(value) = &query.name {
6145        score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
6146    }
6147    if let Some(value) = &query.moniker
6148        && let Some(moniker) = candidate.moniker.as_deref()
6149    {
6150        score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
6151    }
6152    if let Some(value) = &query.tag
6153        && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6154    {
6155        score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
6156    }
6157    if let Some(value) = &query.command
6158        && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6159    {
6160        score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
6161    }
6162
6163    if matches!(candidate.source_kind, SourceKind::PreIndexed) {
6164        score += 5;
6165    }
6166    if search_match_has_unknown_version(candidate) {
6167        score = score.saturating_sub(10);
6168    }
6169
6170    score
6171}
6172
6173fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
6174    candidate
6175        .version
6176        .as_deref()
6177        .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
6178}
6179
6180/// Builds the YAML manifest emitted next to the installer by `winget download`.
6181///
6182/// `documents` is the merged singleton JSON the source provided; we narrow the
6183/// `Installers` array down to just the resolved installer so the file describes
6184/// exactly the binary that was written, mirroring native winget behavior. The
6185/// typed `manifest` overrides identity fields (id, version, name, channel) on
6186/// the JSON because callers like `manifest_for_match` correct those after the
6187/// initial YAML parse.
6188fn build_download_manifest_yaml(manifest: &Manifest, documents: &JsonValue, installer: &Installer) -> Result<String> {
6189    let mut document = match documents {
6190        JsonValue::Object(map) => map.clone(),
6191        _ => serde_json::Map::new(),
6192    };
6193
6194    document.insert("PackageIdentifier".to_owned(), JsonValue::String(manifest.id.clone()));
6195    document.insert("PackageVersion".to_owned(), JsonValue::String(manifest.version.clone()));
6196    document.insert("PackageName".to_owned(), JsonValue::String(manifest.name.clone()));
6197    if !manifest.channel.is_empty() {
6198        document.insert("Channel".to_owned(), JsonValue::String(manifest.channel.clone()));
6199    } else {
6200        document.remove("Channel");
6201    }
6202
6203    // Replace the multi-installer array with the single resolved installer so
6204    // the emitted manifest describes the file the user actually downloaded.
6205    let mut single_installer = serde_json::Map::new();
6206    if let Some(arch) = installer.architecture.as_ref() {
6207        single_installer.insert("Architecture".to_owned(), JsonValue::String(arch.clone()));
6208    }
6209    if let Some(installer_type) = installer.installer_type.as_ref() {
6210        single_installer.insert("InstallerType".to_owned(), JsonValue::String(installer_type.clone()));
6211    }
6212    if let Some(url) = installer.url.as_ref() {
6213        single_installer.insert("InstallerUrl".to_owned(), JsonValue::String(url.clone()));
6214    }
6215    if let Some(sha) = installer.sha256.as_ref() {
6216        single_installer.insert("InstallerSha256".to_owned(), JsonValue::String(sha.clone()));
6217    }
6218    if let Some(scope) = installer.scope.as_ref() {
6219        single_installer.insert("Scope".to_owned(), JsonValue::String(scope.clone()));
6220    }
6221    if let Some(locale) = installer.locale.as_ref() {
6222        single_installer.insert("InstallerLocale".to_owned(), JsonValue::String(locale.clone()));
6223    }
6224    if let Some(product_code) = installer.product_code.as_ref() {
6225        single_installer.insert("ProductCode".to_owned(), JsonValue::String(product_code.clone()));
6226    }
6227    if let Some(release_date) = installer.release_date.as_ref() {
6228        single_installer.insert("ReleaseDate".to_owned(), JsonValue::String(release_date.clone()));
6229    }
6230
6231    document.insert(
6232        "Installers".to_owned(),
6233        JsonValue::Array(vec![JsonValue::Object(single_installer)]),
6234    );
6235    document.insert("ManifestType".to_owned(), JsonValue::String("merged".to_owned()));
6236
6237    let value = JsonValue::Object(document);
6238    serde_yaml::to_string(&value).context("failed to serialize manifest YAML")
6239}
6240
6241/// Computes the file name `winget download` uses for the YAML manifest
6242/// emitted next to the installer:
6243/// `{PackageName}_{Version?}_{Scope?}_{Architecture}_{InstallerType}_{Locale?}.yaml`.
6244/// Mirrors `GetInstallerDownloadOnlyFileName` in winget-cli's DownloadFlow.
6245fn derive_download_manifest_filename(manifest: &Manifest, installer: &Installer) -> String {
6246    let mut filename = derive_download_filename_stem(manifest, installer);
6247    filename.push_str(".yaml");
6248    filename
6249}
6250
6251fn derive_download_filename_stem(manifest: &Manifest, installer: &Installer) -> String {
6252    let mut stem = sanitize_filename_component(&manifest.name);
6253    if !manifest.version.is_empty() && !manifest.version.eq_ignore_ascii_case("Unknown") {
6254        stem.push('_');
6255        stem.push_str(&sanitize_filename_component(&manifest.version));
6256    }
6257    if let Some(scope) = installer.scope.as_deref() {
6258        stem.push('_');
6259        stem.push_str(&sanitize_filename_component(&capitalize_first(scope)));
6260    }
6261    let architecture = installer.architecture.as_deref().unwrap_or("neutral");
6262    stem.push('_');
6263    stem.push_str(&sanitize_filename_component(&format_architecture(architecture)));
6264
6265    let installer_type = installer.installer_type.as_deref().unwrap_or("");
6266    if !installer_type.is_empty() {
6267        stem.push('_');
6268        stem.push_str(&sanitize_filename_component(&installer_type.to_ascii_lowercase()));
6269    }
6270    if let Some(locale) = installer.locale.as_deref().filter(|value| !value.is_empty()) {
6271        stem.push('_');
6272        stem.push_str(&sanitize_filename_component(locale));
6273    }
6274
6275    stem
6276}
6277
6278fn capitalize_first(value: &str) -> String {
6279    let mut chars = value.chars();
6280    match chars.next() {
6281        Some(first) => {
6282            let mut out = first.to_ascii_uppercase().to_string();
6283            out.push_str(&chars.as_str().to_ascii_lowercase());
6284            out
6285        }
6286        None => String::new(),
6287    }
6288}
6289
6290fn format_architecture(value: &str) -> String {
6291    match value.to_ascii_lowercase().as_str() {
6292        "x64" => "X64".to_owned(),
6293        "x86" => "X86".to_owned(),
6294        "arm" => "Arm".to_owned(),
6295        "arm64" => "Arm64".to_owned(),
6296        "neutral" => "Neutral".to_owned(),
6297        other => capitalize_first(other),
6298    }
6299}
6300
6301fn sanitize_filename_component(value: &str) -> String {
6302    value
6303        .chars()
6304        .map(|ch| match ch {
6305            '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
6306            ch if ch.is_control() => '_',
6307            ch => ch,
6308        })
6309        .collect()
6310}
6311
6312fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
6313    if manifest_path.is_file() {
6314        return Ok(manifest_path.to_path_buf());
6315    }
6316
6317    if !manifest_path.is_dir() {
6318        bail!("Manifest path not found: {}", manifest_path.display());
6319    }
6320
6321    let candidate = fs::read_dir(manifest_path)?
6322        .filter_map(|entry| entry.ok())
6323        .map(|entry| entry.path())
6324        .filter(|path| {
6325            path.extension()
6326                .and_then(|value| value.to_str())
6327                .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
6328        })
6329        .min();
6330
6331    candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
6332}
6333
6334fn score_text_match(
6335    candidate: &str,
6336    query: &str,
6337    exact: bool,
6338    exact_score: usize,
6339    prefix_score: usize,
6340    substring_score: usize,
6341) -> usize {
6342    if candidate.eq_ignore_ascii_case(query) {
6343        return exact_score;
6344    }
6345    if exact {
6346        return 0;
6347    }
6348
6349    let candidate_lower = candidate.to_ascii_lowercase();
6350    let query_lower = query.to_ascii_lowercase();
6351    if candidate_lower.starts_with(&query_lower) {
6352        prefix_score
6353    } else if candidate_lower.contains(&query_lower) {
6354        substring_score
6355    } else {
6356        0
6357    }
6358}
6359
6360fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
6361where
6362    I: IntoIterator<Item = String>,
6363{
6364    values
6365        .into_iter()
6366        .filter_map(|candidate| {
6367            let score = score_text_match(&candidate, query, exact, 3, 2, 1);
6368            (score > 0).then_some((score, candidate))
6369        })
6370        .max_by(|(left_score, left_value), (right_score, right_value)| {
6371            left_score
6372                .cmp(right_score)
6373                .then_with(|| right_value.len().cmp(&left_value.len()))
6374                .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
6375        })
6376        .map(|(_, candidate)| candidate)
6377}
6378
6379fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
6380    let (field, value) = criteria.split_once(": ")?;
6381    Some((field, value))
6382}
6383
6384fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
6385    let versions = item
6386        .get("Versions")
6387        .and_then(JsonValue::as_array)
6388        .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
6389    let mut result = Vec::new();
6390    for version in versions {
6391        let value = json_string(version, "PackageVersion")
6392            .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
6393        let channel = json_string(version, "Channel").unwrap_or_default();
6394        result.push(VersionKey {
6395            version: value,
6396            channel,
6397        });
6398    }
6399    Ok(result)
6400}
6401
6402fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
6403    let mut merged = YamlMapping::new();
6404    let mut documents = Vec::new();
6405    for document in serde_yaml::Deserializer::from_slice(bytes) {
6406        let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
6407        documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
6408        if let Some(mapping) = value.as_mapping() {
6409            merge_yaml_mapping(&mut merged, mapping);
6410        }
6411    }
6412
6413    let id = yaml_string_from_root(&merged, "PackageIdentifier")
6414        .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
6415    let version =
6416        yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
6417    let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
6418
6419    let installers = parse_yaml_installers(&merged);
6420    let top_level_require_explicit = yaml_scalar_bool(&merged, "RequireExplicitUpgrade");
6421    let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
6422
6423    Ok((
6424        Manifest {
6425            id,
6426            name,
6427            version,
6428            channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
6429            publisher: yaml_localized_string(&merged, "Publisher"),
6430            description: yaml_localized_string(&merged, "Description")
6431                .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
6432            moniker: yaml_string_from_root(&merged, "Moniker"),
6433            package_url: yaml_localized_string(&merged, "PackageUrl"),
6434            publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
6435            publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
6436            license: yaml_localized_string(&merged, "License"),
6437            license_url: yaml_localized_string(&merged, "LicenseUrl"),
6438            privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
6439            author: yaml_localized_string(&merged, "Author"),
6440            copyright: yaml_localized_string(&merged, "Copyright"),
6441            copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
6442            release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
6443            release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
6444            tags: yaml_string_list(&merged, "Tags"),
6445            agreements: yaml_agreement_list(&merged),
6446            package_dependencies: yaml_package_dependencies(&merged),
6447            documentation: yaml_documentation_list(&merged),
6448            icons: yaml_icon_list(&merged),
6449            installers,
6450            require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
6451        },
6452        collapse_structured_document(&JsonValue::Array(documents)),
6453    ))
6454}
6455
6456fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
6457    parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
6458}
6459
6460fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
6461    let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
6462    let data = root
6463        .get("Data")
6464        .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
6465    let versions = data
6466        .get("Versions")
6467        .and_then(JsonValue::as_array)
6468        .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
6469    let selected = versions
6470        .iter()
6471        .find(|item| {
6472            json_string(item, "PackageVersion").as_deref() == Some(version)
6473                && json_string(item, "Channel").unwrap_or_default() == channel
6474        })
6475        .or_else(|| versions.first())
6476        .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
6477
6478    let default_locale = selected
6479        .get("DefaultLocale")
6480        .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
6481    let package_default_locale = data.get("DefaultLocale");
6482    let name = json_string(default_locale, "PackageName")
6483        .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
6484    let installer_switch_defaults = json_installer_switches(selected);
6485    let top_platforms = json_string_list(selected, "Platform");
6486    let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
6487    let top_installer_type = json_string(selected, "InstallerType");
6488    let top_nested_installer_type = json_string(selected, "NestedInstallerType");
6489    let top_nested_installer_files = json_nested_installer_files(selected);
6490
6491    let installers = selected
6492        .get("Installers")
6493        .and_then(JsonValue::as_array)
6494        .map(|items| {
6495            items
6496                .iter()
6497                .map(|item| Installer {
6498                    platforms: {
6499                        let platforms = json_string_list(item, "Platform");
6500                        if platforms.is_empty() {
6501                            top_platforms.clone()
6502                        } else {
6503                            platforms
6504                        }
6505                    },
6506                    minimum_os_version: json_string(item, "MinimumOSVersion")
6507                        .or_else(|| top_minimum_os_version.clone()),
6508                    architecture: json_string(item, "Architecture"),
6509                    installer_type: json_string(item, "InstallerType").or_else(|| top_installer_type.clone()),
6510                    nested_installer_type: json_string(item, "NestedInstallerType")
6511                        .or_else(|| top_nested_installer_type.clone()),
6512                    nested_installer_files: {
6513                        let files = json_nested_installer_files(item);
6514                        if files.is_empty() {
6515                            top_nested_installer_files.clone()
6516                        } else {
6517                            files
6518                        }
6519                    },
6520                    url: json_string(item, "InstallerUrl"),
6521                    sha256: json_string(item, "InstallerSha256"),
6522                    product_code: json_string(item, "ProductCode"),
6523                    locale: json_string(item, "InstallerLocale"),
6524                    scope: json_string(item, "Scope"),
6525                    release_date: json_string(item, "ReleaseDate"),
6526                    package_family_name: json_string(item, "PackageFamilyName"),
6527                    upgrade_code: json_string(item, "UpgradeCode"),
6528                    switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
6529                    commands: json_string_list(item, "Commands"),
6530                    package_dependencies: json_package_dependencies(item),
6531                    require_explicit_upgrade: json_bool(item, "RequireExplicitUpgrade"),
6532                })
6533                .collect::<Vec<_>>()
6534        })
6535        .unwrap_or_default();
6536    let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade");
6537    let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
6538    let mut icons = json_icon_list(default_locale);
6539    if icons.is_empty() {
6540        icons = package_default_locale.map(json_icon_list).unwrap_or_default();
6541    }
6542
6543    let manifest = Manifest {
6544        id: package_id.to_owned(),
6545        name,
6546        version: version.to_owned(),
6547        channel: channel.to_owned(),
6548        publisher: json_string(default_locale, "Publisher"),
6549        description: json_string(default_locale, "Description")
6550            .or_else(|| json_string(default_locale, "ShortDescription")),
6551        moniker: json_string(default_locale, "Moniker"),
6552        package_url: json_string(default_locale, "PackageUrl"),
6553        publisher_url: json_string(default_locale, "PublisherUrl"),
6554        publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
6555        license: json_string(default_locale, "License"),
6556        license_url: json_string(default_locale, "LicenseUrl"),
6557        privacy_url: json_string(default_locale, "PrivacyUrl"),
6558        author: json_string(default_locale, "Author"),
6559        copyright: json_string(default_locale, "Copyright"),
6560        copyright_url: json_string(default_locale, "CopyrightUrl"),
6561        release_notes: json_string(default_locale, "ReleaseNotes"),
6562        release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
6563        tags: json_string_list(default_locale, "Tags"),
6564        agreements: json_agreement_list(default_locale),
6565        package_dependencies: json_package_dependencies(selected),
6566        documentation: json_documentation_list(default_locale),
6567        icons,
6568        installers,
6569        require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
6570    };
6571
6572    Ok((
6573        manifest,
6574        build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
6575    ))
6576}
6577
6578fn collapse_structured_document(document: &JsonValue) -> JsonValue {
6579    match document {
6580        JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
6581            merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
6582        }
6583        JsonValue::Array(documents) => merge_manifest_documents(documents),
6584        JsonValue::Object(_) if is_merged_manifest_document(document) => {
6585            merge_manifest_documents(&split_merged_manifest_document(document))
6586        }
6587        _ => document.clone(),
6588    }
6589}
6590
6591fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
6592    documents.iter().map(collapse_structured_document).collect()
6593}
6594
6595fn add_icon_fields_to_structured_document(mut document: JsonValue, manifest: &Manifest) -> JsonValue {
6596    let Some(root) = document.as_object_mut() else {
6597        return document;
6598    };
6599
6600    root.insert(
6601        "IconUrl".to_owned(),
6602        manifest
6603            .icon_url()
6604            .map(|url| JsonValue::String(url.to_owned()))
6605            .unwrap_or(JsonValue::Null),
6606    );
6607    root.insert(
6608        "Icon".to_owned(),
6609        manifest.icon().map(icon_to_json_value).unwrap_or(JsonValue::Null),
6610    );
6611    root.entry("Icons".to_owned())
6612        .or_insert_with(|| serde_json::to_value(&manifest.icons).expect("package icon serialization cannot fail"));
6613
6614    document
6615}
6616
6617fn icon_to_json_value(icon: &PackageIcon) -> JsonValue {
6618    serde_json::to_value(icon).expect("package icon serialization cannot fail")
6619}
6620
6621fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
6622    let version = documents.iter().find(|document| {
6623        document
6624            .get("ManifestType")
6625            .and_then(JsonValue::as_str)
6626            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
6627    });
6628    let default_locale = documents.iter().find(|document| {
6629        document
6630            .get("ManifestType")
6631            .and_then(JsonValue::as_str)
6632            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
6633    });
6634    let installer = documents.iter().find(|document| {
6635        document
6636            .get("ManifestType")
6637            .and_then(JsonValue::as_str)
6638            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
6639    });
6640
6641    let mut singleton = JsonMap::new();
6642    copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
6643    copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
6644    copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
6645
6646    if !singleton.contains_key("PackageLocale")
6647        && let Some(package_locale) = default_locale
6648            .and_then(|document| json_string(document, "PackageLocale"))
6649            .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
6650    {
6651        singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
6652    }
6653
6654    singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
6655    singleton.insert(
6656        "ManifestVersion".to_owned(),
6657        JsonValue::String(
6658            installer
6659                .and_then(|document| json_string(document, "ManifestVersion"))
6660                .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
6661                .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
6662                .unwrap_or_else(|| "1.10.0".to_owned()),
6663        ),
6664    );
6665    singleton.remove("DefaultLocale");
6666
6667    JsonValue::Object(singleton)
6668}
6669
6670fn is_merged_manifest_document(document: &JsonValue) -> bool {
6671    document
6672        .get("ManifestType")
6673        .and_then(JsonValue::as_str)
6674        .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
6675}
6676
6677fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
6678    let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
6679    let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
6680    let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
6681    let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
6682
6683    let version_document = serde_json::json!({
6684        "PackageIdentifier": package_identifier,
6685        "PackageVersion": package_version,
6686        "DefaultLocale": package_locale,
6687        "ManifestType": "version",
6688        "ManifestVersion": manifest_version,
6689    });
6690
6691    let mut default_locale_document = project_manifest_document(
6692        merged,
6693        &[
6694            "PackageIdentifier",
6695            "PackageVersion",
6696            "PackageLocale",
6697            "Publisher",
6698            "PublisherUrl",
6699            "PublisherSupportUrl",
6700            "PrivacyUrl",
6701            "Author",
6702            "PackageName",
6703            "PackageUrl",
6704            "License",
6705            "LicenseUrl",
6706            "Copyright",
6707            "CopyrightUrl",
6708            "ShortDescription",
6709            "Description",
6710            "Moniker",
6711            "Tags",
6712            "Agreements",
6713            "ReleaseNotes",
6714            "ReleaseNotesUrl",
6715            "PurchaseUrl",
6716            "InstallationNotes",
6717            "Documentations",
6718            "Icons",
6719        ],
6720    );
6721    default_locale_document.insert(
6722        "PackageIdentifier".to_owned(),
6723        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
6724    );
6725    default_locale_document.insert(
6726        "PackageVersion".to_owned(),
6727        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
6728    );
6729    default_locale_document.insert(
6730        "PackageLocale".to_owned(),
6731        JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
6732    );
6733    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
6734    default_locale_document.insert(
6735        "ManifestVersion".to_owned(),
6736        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
6737    );
6738
6739    let mut installer_document = project_manifest_document(
6740        merged,
6741        &[
6742            "PackageIdentifier",
6743            "PackageVersion",
6744            "Channel",
6745            "InstallerLocale",
6746            "Platform",
6747            "MinimumOSVersion",
6748            "InstallerType",
6749            "NestedInstallerType",
6750            "NestedInstallerFiles",
6751            "Scope",
6752            "InstallModes",
6753            "InstallerSwitches",
6754            "InstallerSuccessCodes",
6755            "ExpectedReturnCodes",
6756            "UpgradeBehavior",
6757            "Commands",
6758            "Protocols",
6759            "FileExtensions",
6760            "Dependencies",
6761            "PackageFamilyName",
6762            "ProductCode",
6763            "Capabilities",
6764            "RestrictedCapabilities",
6765            "Markets",
6766            "InstallerAbortsTerminal",
6767            "ReleaseDate",
6768            "InstallLocationRequired",
6769            "RequireExplicitUpgrade",
6770            "DisplayInstallWarnings",
6771            "UnsupportedOSArchitectures",
6772            "UnsupportedArguments",
6773            "AppsAndFeaturesEntries",
6774            "ElevationRequirement",
6775            "InstallationMetadata",
6776            "DownloadCommandProhibited",
6777            "RepairBehavior",
6778            "ArchiveBinariesDependOnPath",
6779            "Authentication",
6780            "Installers",
6781        ],
6782    );
6783    installer_document.insert(
6784        "PackageIdentifier".to_owned(),
6785        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
6786    );
6787    installer_document.insert(
6788        "PackageVersion".to_owned(),
6789        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
6790    );
6791    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
6792    installer_document.insert(
6793        "ManifestVersion".to_owned(),
6794        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
6795    );
6796
6797    vec![
6798        version_document,
6799        JsonValue::Object(default_locale_document),
6800        JsonValue::Object(installer_document),
6801    ]
6802}
6803
6804fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
6805    let mut result = JsonMap::new();
6806    if let Some(source) = source.as_object() {
6807        for key in keys {
6808            if let Some(value) = source.get(*key) {
6809                result.insert((*key).to_owned(), value.clone());
6810            }
6811        }
6812    }
6813
6814    result
6815}
6816
6817fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
6818    if let Some(source) = source.and_then(JsonValue::as_object) {
6819        for key in keys {
6820            if let Some(value) = source.get(*key) {
6821                target.insert((*key).to_owned(), value.clone());
6822            }
6823        }
6824    }
6825}
6826
6827fn copy_all_manifest_keys_except(
6828    target: &mut JsonMap<String, JsonValue>,
6829    source: Option<&JsonValue>,
6830    excluded_keys: &[&str],
6831) {
6832    if let Some(source) = source.and_then(JsonValue::as_object) {
6833        for (key, value) in source {
6834            if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
6835                target.insert(key.clone(), value.clone());
6836            }
6837        }
6838    }
6839}
6840
6841fn build_rest_manifest_documents(
6842    data: &JsonValue,
6843    installer_source: &JsonValue,
6844    default_locale: &JsonValue,
6845    package_id: &str,
6846    version: &str,
6847    channel: &str,
6848) -> JsonValue {
6849    let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
6850    let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
6851    let manifest_version = json_string(installer_source, "ManifestVersion")
6852        .or_else(|| json_string(default_locale, "ManifestVersion"))
6853        .or_else(|| json_string(data, "ManifestVersion"))
6854        .unwrap_or_else(|| "1.10.0".to_owned());
6855
6856    let mut version_document = JsonMap::new();
6857    version_document.insert(
6858        "PackageIdentifier".to_owned(),
6859        JsonValue::String(package_identifier.clone()),
6860    );
6861    version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6862    version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
6863    version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
6864    version_document.insert(
6865        "ManifestVersion".to_owned(),
6866        JsonValue::String(manifest_version.clone()),
6867    );
6868
6869    let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
6870    default_locale_document.insert(
6871        "PackageIdentifier".to_owned(),
6872        JsonValue::String(package_identifier.clone()),
6873    );
6874    default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6875    default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
6876    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
6877    default_locale_document.insert(
6878        "ManifestVersion".to_owned(),
6879        JsonValue::String(manifest_version.clone()),
6880    );
6881
6882    let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
6883    installer_document.remove("DefaultLocale");
6884    installer_document.remove("Locales");
6885    installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
6886    installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6887    if !channel.is_empty() {
6888        installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
6889    }
6890    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
6891    installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
6892
6893    let documents = vec![
6894        JsonValue::Object(version_document),
6895        JsonValue::Object(default_locale_document),
6896        JsonValue::Object(installer_document),
6897    ];
6898
6899    merge_manifest_documents(&documents)
6900}
6901
6902fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
6903    let base = installer_defaults(root);
6904    let base_switches = yaml_installer_switches(root);
6905    if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
6906        let installers = items
6907            .iter()
6908            .filter_map(YamlValue::as_mapping)
6909            .map(|item| {
6910                let mut merged = base.clone();
6911                merge_yaml_mapping(&mut merged, item);
6912                let switches = yaml_installer_switches(item).with_fallback(&base_switches);
6913                installer_from_yaml(&merged, switches)
6914            })
6915            .collect::<Vec<_>>();
6916        if !installers.is_empty() {
6917            return installers;
6918        }
6919    }
6920
6921    if base.is_empty() {
6922        Vec::new()
6923    } else {
6924        vec![installer_from_yaml(&base, base_switches)]
6925    }
6926}
6927
6928fn installer_defaults(root: &YamlMapping) -> YamlMapping {
6929    let keys = [
6930        "Architecture",
6931        "InstallerType",
6932        "NestedInstallerType",
6933        "NestedInstallerFiles",
6934        "InstallerUrl",
6935        "InstallerSha256",
6936        "ProductCode",
6937        "InstallerLocale",
6938        "Platform",
6939        "MinimumOSVersion",
6940        "Scope",
6941        "ReleaseDate",
6942        "PackageFamilyName",
6943        "UpgradeCode",
6944        "Commands",
6945    ];
6946    let mut defaults = YamlMapping::new();
6947    for key in keys {
6948        if let Some(value) = root.get(YamlValue::from(key)) {
6949            defaults.insert(YamlValue::from(key), value.clone());
6950        }
6951    }
6952    defaults
6953}
6954
6955fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
6956    Installer {
6957        architecture: yaml_string(root, "Architecture"),
6958        installer_type: yaml_string(root, "InstallerType"),
6959        nested_installer_type: yaml_string(root, "NestedInstallerType"),
6960        nested_installer_files: yaml_nested_installer_files(root),
6961        url: yaml_string(root, "InstallerUrl"),
6962        sha256: yaml_string(root, "InstallerSha256"),
6963        product_code: yaml_string(root, "ProductCode"),
6964        locale: yaml_string(root, "InstallerLocale"),
6965        scope: yaml_string(root, "Scope"),
6966        release_date: yaml_string(root, "ReleaseDate"),
6967        package_family_name: yaml_string(root, "PackageFamilyName"),
6968        upgrade_code: yaml_string(root, "UpgradeCode"),
6969        platforms: yaml_string_list(root, "Platform"),
6970        minimum_os_version: yaml_string(root, "MinimumOSVersion"),
6971        switches,
6972        commands: yaml_string_list(root, "Commands"),
6973        package_dependencies: yaml_package_dependencies(root),
6974        require_explicit_upgrade: yaml_scalar_bool(root, "RequireExplicitUpgrade"),
6975    }
6976}
6977
6978/// Reads a YAML scalar boolean. Accepts `true`/`false` (any casing) plus
6979/// the legacy `True`/`False` variants the winget catalog mixes in.
6980fn yaml_scalar_bool(root: &YamlMapping, key: &str) -> bool {
6981    let Some(value) = root.get(YamlValue::from(key)) else {
6982        return false;
6983    };
6984    match value {
6985        YamlValue::Bool(b) => *b,
6986        YamlValue::String(s) => s.eq_ignore_ascii_case("true"),
6987        _ => false,
6988    }
6989}
6990
6991fn json_bool(value: &JsonValue, key: &str) -> bool {
6992    match value.get(key) {
6993        Some(JsonValue::Bool(b)) => *b,
6994        Some(JsonValue::String(s)) => s.eq_ignore_ascii_case("true"),
6995        _ => false,
6996    }
6997}
6998
6999fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
7000    let mapping = root
7001        .get(YamlValue::from("InstallerSwitches"))
7002        .or_else(|| root.get(YamlValue::from("Switches")))
7003        .and_then(YamlValue::as_mapping);
7004    yaml_installer_switches_from_mapping(mapping)
7005}
7006
7007fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
7008    let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
7009
7010    InstallerSwitches {
7011        silent: string("Silent"),
7012        silent_with_progress: string("SilentWithProgress"),
7013        interactive: string("Interactive"),
7014        custom: string("Custom"),
7015        log: string("Log"),
7016        install_location: string("InstallLocation"),
7017    }
7018}
7019
7020fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
7021    yaml_string(root, key).or_else(|| {
7022        root.get(YamlValue::from("DefaultLocale"))
7023            .and_then(YamlValue::as_mapping)
7024            .and_then(|mapping| yaml_string(mapping, key))
7025    })
7026}
7027
7028fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
7029    yaml_string(root, key)
7030}
7031
7032fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
7033    root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
7034}
7035
7036fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
7037    match value {
7038        YamlValue::Null => None,
7039        YamlValue::Bool(value) => Some(value.to_string()),
7040        YamlValue::Number(value) => Some(value.to_string()),
7041        YamlValue::String(value) => Some(value.clone()),
7042        YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
7043        YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
7044    }
7045}
7046
7047fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
7048    root.get(YamlValue::from(key))
7049        .and_then(YamlValue::as_sequence)
7050        .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
7051        .unwrap_or_default()
7052}
7053
7054fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
7055    root.get(YamlValue::from("Documentations"))
7056        .and_then(YamlValue::as_sequence)
7057        .map(|items| {
7058            items
7059                .iter()
7060                .filter_map(YamlValue::as_mapping)
7061                .filter_map(|item| {
7062                    let url = yaml_string(item, "DocumentUrl")?;
7063                    Some(Documentation {
7064                        label: yaml_string(item, "DocumentLabel"),
7065                        url,
7066                    })
7067                })
7068                .collect()
7069        })
7070        .unwrap_or_default()
7071}
7072
7073fn yaml_icon_list(root: &YamlMapping) -> Vec<PackageIcon> {
7074    root.get(YamlValue::from("Icons"))
7075        .and_then(YamlValue::as_sequence)
7076        .map(|items| {
7077            items
7078                .iter()
7079                .filter_map(YamlValue::as_mapping)
7080                .filter_map(|item| {
7081                    let icon = PackageIcon {
7082                        icon_url: yaml_string(item, "IconUrl"),
7083                        icon_file_type: yaml_string(item, "IconFileType"),
7084                        icon_resolution: yaml_string(item, "IconResolution"),
7085                        icon_theme: yaml_string(item, "IconTheme"),
7086                        icon_sha256: yaml_string(item, "IconSha256"),
7087                    };
7088                    (!icon.is_empty()).then_some(icon)
7089                })
7090                .collect()
7091        })
7092        .unwrap_or_default()
7093}
7094
7095fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
7096    root.get(YamlValue::from("Agreements"))
7097        .and_then(YamlValue::as_sequence)
7098        .map(|items| {
7099            items
7100                .iter()
7101                .filter_map(YamlValue::as_mapping)
7102                .map(|item| PackageAgreement {
7103                    label: yaml_string(item, "AgreementLabel"),
7104                    text: yaml_string(item, "Agreement"),
7105                    url: yaml_string(item, "AgreementUrl"),
7106                })
7107                .collect()
7108        })
7109        .unwrap_or_default()
7110}
7111
7112fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
7113    root.get(YamlValue::from("Dependencies"))
7114        .and_then(YamlValue::as_mapping)
7115        .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
7116        .and_then(YamlValue::as_sequence)
7117        .map(|items| {
7118            items
7119                .iter()
7120                .filter_map(YamlValue::as_mapping)
7121                .filter_map(|item| yaml_string(item, "PackageIdentifier"))
7122                .collect()
7123        })
7124        .unwrap_or_default()
7125}
7126
7127fn yaml_nested_installer_files(root: &YamlMapping) -> Vec<NestedInstallerFile> {
7128    root.get(YamlValue::from("NestedInstallerFiles"))
7129        .and_then(YamlValue::as_sequence)
7130        .map(|items| {
7131            items
7132                .iter()
7133                .filter_map(YamlValue::as_mapping)
7134                .filter_map(|item| {
7135                    let relative = yaml_string(item, "RelativeFilePath")?;
7136                    Some(NestedInstallerFile {
7137                        relative_file_path: relative,
7138                        portable_command_alias: yaml_string(item, "PortableCommandAlias"),
7139                    })
7140                })
7141                .collect()
7142        })
7143        .unwrap_or_default()
7144}
7145
7146fn json_string(value: &JsonValue, key: &str) -> Option<String> {
7147    value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
7148}
7149
7150fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
7151    value
7152        .get(key)
7153        .and_then(JsonValue::as_array)
7154        .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
7155        .unwrap_or_default()
7156}
7157
7158fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
7159    value
7160        .get("Documentations")
7161        .and_then(JsonValue::as_array)
7162        .map(|items| {
7163            items
7164                .iter()
7165                .filter_map(|item| {
7166                    let url = json_string(item, "DocumentUrl")?;
7167                    Some(Documentation {
7168                        label: json_string(item, "DocumentLabel"),
7169                        url,
7170                    })
7171                })
7172                .collect()
7173        })
7174        .unwrap_or_default()
7175}
7176
7177fn json_icon_list(value: &JsonValue) -> Vec<PackageIcon> {
7178    value
7179        .get("Icons")
7180        .and_then(JsonValue::as_array)
7181        .map(|items| {
7182            items
7183                .iter()
7184                .filter_map(|item| {
7185                    let icon = PackageIcon {
7186                        icon_url: json_string(item, "IconUrl"),
7187                        icon_file_type: json_string(item, "IconFileType"),
7188                        icon_resolution: json_string(item, "IconResolution"),
7189                        icon_theme: json_string(item, "IconTheme"),
7190                        icon_sha256: json_string(item, "IconSha256"),
7191                    };
7192                    (!icon.is_empty()).then_some(icon)
7193                })
7194                .collect()
7195        })
7196        .unwrap_or_default()
7197}
7198
7199fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
7200    value
7201        .get("Agreements")
7202        .and_then(JsonValue::as_array)
7203        .map(|items| {
7204            items
7205                .iter()
7206                .map(|item| PackageAgreement {
7207                    label: json_string(item, "AgreementLabel"),
7208                    text: json_string(item, "Agreement"),
7209                    url: json_string(item, "AgreementUrl"),
7210                })
7211                .collect()
7212        })
7213        .unwrap_or_default()
7214}
7215
7216fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
7217    value
7218        .get("Dependencies")
7219        .and_then(JsonValue::as_object)
7220        .and_then(|deps| deps.get("PackageDependencies"))
7221        .and_then(JsonValue::as_array)
7222        .map(|items| {
7223            items
7224                .iter()
7225                .filter_map(|item| json_string(item, "PackageIdentifier"))
7226                .collect()
7227        })
7228        .unwrap_or_default()
7229}
7230
7231fn json_nested_installer_files(value: &JsonValue) -> Vec<NestedInstallerFile> {
7232    value
7233        .get("NestedInstallerFiles")
7234        .and_then(JsonValue::as_array)
7235        .map(|items| {
7236            items
7237                .iter()
7238                .filter_map(|item| {
7239                    let relative = json_string(item, "RelativeFilePath")?;
7240                    Some(NestedInstallerFile {
7241                        relative_file_path: relative,
7242                        portable_command_alias: json_string(item, "PortableCommandAlias"),
7243                    })
7244                })
7245                .collect()
7246        })
7247        .unwrap_or_default()
7248}
7249
7250fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
7251    let switches = value
7252        .get("InstallerSwitches")
7253        .or_else(|| value.get("Switches"))
7254        .and_then(JsonValue::as_object);
7255
7256    let string = |key: &str| {
7257        switches
7258            .and_then(|switches| switches.get(key))
7259            .and_then(JsonValue::as_str)
7260            .map(str::to_owned)
7261    };
7262
7263    InstallerSwitches {
7264        silent: string("Silent"),
7265        silent_with_progress: string("SilentWithProgress"),
7266        interactive: string("Interactive"),
7267        custom: string("Custom"),
7268        log: string("Log"),
7269        install_location: string("InstallLocation"),
7270    }
7271}
7272
7273fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
7274    for (key, value) in source {
7275        target.insert(key.clone(), value.clone());
7276    }
7277}
7278
7279fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
7280    let reader = Cursor::new(bytes.to_vec());
7281    let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
7282    let mut file = archive
7283        .by_name(member_name)
7284        .with_context(|| format!("zip payload missing {member_name}"))?;
7285    let mut output = Vec::new();
7286    file.read_to_end(&mut output)
7287        .with_context(|| format!("failed to read {member_name} from zip payload"))?;
7288    Ok(output)
7289}
7290
7291fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
7292    if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
7293        return Ok(output);
7294    }
7295
7296    if !bytes.starts_with(b"CK")
7297        && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
7298    {
7299        return decompress_mszyml_payload(&bytes[offset + 2..]);
7300    }
7301
7302    decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
7303}
7304
7305fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
7306    let mut decoder = flate2::read::DeflateDecoder::new(payload);
7307    let mut output = String::new();
7308    decoder.read_to_string(&mut output)?;
7309    Ok(output)
7310}
7311
7312fn cache_control_max_age(response: &Response) -> u64 {
7313    response
7314        .headers()
7315        .get(reqwest::header::CACHE_CONTROL)
7316        .and_then(|value| value.to_str().ok())
7317        .and_then(|header| {
7318            header.split(',').find_map(|part| {
7319                let trimmed = part.trim();
7320                trimmed
7321                    .strip_prefix("max-age=")
7322                    .and_then(|value| value.parse::<u64>().ok())
7323            })
7324        })
7325        .unwrap_or(60)
7326}
7327
7328fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
7329    REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
7330        let (major, minor) = major_minor(candidate);
7331        server_versions.iter().any(|version| {
7332            let (server_major, server_minor) = major_minor(version);
7333            server_major == major && server_minor == minor
7334        })
7335    })
7336}
7337
7338fn major_minor(version: &str) -> (String, String) {
7339    let mut parts = version.split('.');
7340    (
7341        parts.next().unwrap_or_default().to_owned(),
7342        parts.next().unwrap_or_default().to_owned(),
7343    )
7344}
7345
7346fn default_market() -> String {
7347    std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
7348}
7349
7350fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
7351    let requested_locale = query.locale.as_deref();
7352    let requested_architecture = query.installer_architecture.as_deref();
7353    let requested_type = query.installer_type.as_deref();
7354    let requested_scope = query.install_scope.as_deref();
7355    let requested_platform = query.platform.as_deref();
7356    let requested_os_version = query.os_version.as_deref();
7357    let system_architecture = current_architecture();
7358    let system_platform = current_platform();
7359    let current_os_version = current_os_version();
7360
7361    installers
7362        .iter()
7363        .enumerate()
7364        .filter(|(_, installer)| {
7365            installer_matches_requested(
7366                installer,
7367                requested_type,
7368                requested_scope,
7369                requested_platform,
7370                requested_os_version,
7371                system_platform,
7372                current_os_version.as_deref(),
7373            )
7374        })
7375        .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
7376        .max_by_key(|(index, installer)| {
7377            (
7378                installer_rank(installer, requested_locale, requested_architecture, system_architecture),
7379                std::cmp::Reverse(*index),
7380            )
7381        })
7382        .map(|(_, installer)| installer)
7383        .cloned()
7384}
7385
7386fn installer_matches_requested(
7387    installer: &Installer,
7388    requested_type: Option<&str>,
7389    requested_scope: Option<&str>,
7390    requested_platform: Option<&str>,
7391    requested_os_version: Option<&str>,
7392    system_platform: Option<&str>,
7393    current_os_version: Option<&str>,
7394) -> bool {
7395    matches_optional_ci(installer.installer_type.as_deref(), requested_type)
7396        && matches_optional_ci(installer.scope.as_deref(), requested_scope)
7397        && installer_matches_platform(installer, requested_platform, system_platform)
7398        && installer_matches_os_version(installer, requested_os_version, current_os_version)
7399}
7400
7401fn installer_matches_platform(
7402    installer: &Installer,
7403    requested_platform: Option<&str>,
7404    system_platform: Option<&str>,
7405) -> bool {
7406    if installer.platforms.is_empty() {
7407        return true;
7408    }
7409
7410    let Some(effective_platform) = requested_platform.or(system_platform) else {
7411        return true;
7412    };
7413
7414    installer
7415        .platforms
7416        .iter()
7417        .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
7418}
7419
7420fn installer_matches_os_version(
7421    installer: &Installer,
7422    requested_os_version: Option<&str>,
7423    current_os_version: Option<&str>,
7424) -> bool {
7425    let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
7426        return true;
7427    };
7428
7429    let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
7430        return true;
7431    };
7432
7433    match (
7434        parse_dotted_version(minimum_os_version),
7435        parse_dotted_version(actual_os_version),
7436    ) {
7437        (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
7438        _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
7439    }
7440}
7441
7442fn installer_matches_architecture(
7443    installer: &Installer,
7444    requested_architecture: Option<&str>,
7445    system_architecture: &str,
7446) -> bool {
7447    let Some(architecture) = installer.architecture.as_deref() else {
7448        return true;
7449    };
7450
7451    let architecture = architecture.to_ascii_lowercase();
7452    if let Some(requested) = requested_architecture {
7453        return architecture.eq_ignore_ascii_case(requested);
7454    }
7455
7456    preferred_architectures(system_architecture)
7457        .iter()
7458        .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
7459}
7460
7461fn installer_rank(
7462    installer: &Installer,
7463    requested_locale: Option<&str>,
7464    requested_architecture: Option<&str>,
7465    system_architecture: &str,
7466) -> (i32, i32, i32) {
7467    let architecture_rank = architecture_rank(
7468        installer.architecture.as_deref(),
7469        requested_architecture.unwrap_or(system_architecture),
7470        requested_architecture.is_some(),
7471        system_architecture,
7472    );
7473    let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
7474    let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
7475    (architecture_rank, locale_rank, command_rank)
7476}
7477
7478fn architecture_rank(
7479    installer_architecture: Option<&str>,
7480    preferred_architecture: &str,
7481    strict: bool,
7482    system_architecture: &str,
7483) -> i32 {
7484    let Some(value) = installer_architecture else {
7485        return 0;
7486    };
7487    if value.eq_ignore_ascii_case(preferred_architecture) {
7488        return 5;
7489    }
7490    if value.eq_ignore_ascii_case("neutral") {
7491        return 4;
7492    }
7493    if strict {
7494        return -1;
7495    }
7496
7497    preferred_architectures(system_architecture)
7498        .iter()
7499        .rev()
7500        .position(|candidate| value.eq_ignore_ascii_case(candidate))
7501        .and_then(|index| i32::try_from(index).ok())
7502        .and_then(|index| index.checked_add(1))
7503        .unwrap_or(-1)
7504}
7505
7506/// True when at least one installer in `manifest.installers` targets an
7507/// architecture the host can run. winget hides upgrades whose latest
7508/// version publishes no compatible installer.
7509fn manifest_has_compatible_installer(manifest: &Manifest) -> bool {
7510    if manifest.installers.is_empty() {
7511        return true;
7512    }
7513    let allowed = preferred_architectures(current_architecture());
7514    manifest.installers.iter().any(|installer| {
7515        let arch = installer.architecture.as_deref().unwrap_or("neutral");
7516        allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch))
7517    })
7518}
7519
7520fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
7521    match system_architecture {
7522        "arm64" => &["arm64", "neutral", "x64", "x86"],
7523        "x64" => &["x64", "neutral", "x86"],
7524        "x86" => &["x86", "neutral"],
7525        _ => &["neutral"],
7526    }
7527}
7528
7529fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
7530    match (installer_locale, requested_locale) {
7531        (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
7532        (Some(installer), Some(requested))
7533            if installer
7534                .split('-')
7535                .next()
7536                .zip(requested.split('-').next())
7537                .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
7538        {
7539            2
7540        }
7541        (None, Some(_)) => 1,
7542        (Some(_), None) => 1,
7543        (None, None) => 0,
7544        _ => -1,
7545    }
7546}
7547
7548fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
7549    match requested {
7550        Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
7551        None => true,
7552    }
7553}
7554
7555#[cfg(windows)]
7556fn current_architecture() -> &'static str {
7557    use std::sync::OnceLock;
7558    static CELL: OnceLock<&'static str> = OnceLock::new();
7559    CELL.get_or_init(|| {
7560        // IsWow64Process2's `native_machine` is the OS architecture even
7561        // when the process is running under emulation (x64 binary on
7562        // arm64 Windows, x86 on x64). Compile-time `consts::ARCH` would
7563        // mis-report the host arch in that case.
7564        use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2};
7565        let mut process_machine: u16 = 0;
7566        let mut native_machine: u16 = 0;
7567        // SAFETY: GetCurrentProcess returns a pseudo-handle that never fails;
7568        // both out-pointers reference local stack u16s that outlive the call.
7569        let handle = unsafe { GetCurrentProcess() };
7570        // SAFETY: handle is valid per above; pointers are valid.
7571        let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) };
7572        if ok != 0 {
7573            match native_machine {
7574                0xAA64 => return "arm64",
7575                0x8664 => return "x64",
7576                0x014C => return "x86",
7577                _ => {}
7578            }
7579        }
7580        compiled_architecture()
7581    })
7582}
7583
7584#[cfg(not(windows))]
7585fn current_architecture() -> &'static str {
7586    compiled_architecture()
7587}
7588
7589fn compiled_architecture() -> &'static str {
7590    match std::env::consts::ARCH {
7591        "x86_64" => "x64",
7592        "x86" => "x86",
7593        "aarch64" => "arm64",
7594        _ => "neutral",
7595    }
7596}
7597
7598fn current_platform() -> Option<&'static str> {
7599    if cfg!(windows) { Some("Windows.Desktop") } else { None }
7600}
7601
7602#[cfg(windows)]
7603fn current_os_version() -> Option<String> {
7604    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
7605    let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
7606    let build: String = key.get_value("CurrentBuildNumber").ok()?;
7607    let ubr: u32 = key.get_value("UBR").unwrap_or(0);
7608    let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
7609    let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
7610    Some(format!("{major}.{minor}.{build}.{ubr}"))
7611}
7612
7613#[cfg(not(windows))]
7614fn current_os_version() -> Option<String> {
7615    None
7616}
7617
7618fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
7619    let mut parsed = Vec::new();
7620    for component in version.split('.') {
7621        if component.is_empty() {
7622            continue;
7623        }
7624        parsed.push(component.parse().ok()?);
7625    }
7626
7627    if parsed.is_empty() { None } else { Some(parsed) }
7628}
7629
7630fn select_v1_version(
7631    versions: &[V1VersionRow],
7632    requested_version: Option<&str>,
7633    requested_channel: Option<&str>,
7634    package_id: &str,
7635    source_name: &str,
7636) -> Result<V1VersionRow> {
7637    let mut ordered = versions.to_vec();
7638    ordered.sort_by(|left, right| {
7639        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
7640    });
7641
7642    if let Some(version) = requested_version {
7643        let channel = requested_channel.unwrap_or_default();
7644        return ordered
7645            .iter()
7646            .find(|candidate| {
7647                candidate.version.eq_ignore_ascii_case(version)
7648                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
7649            })
7650            .cloned()
7651            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
7652    }
7653
7654    if let Some(channel) = requested_channel {
7655        return ordered
7656            .iter()
7657            .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
7658            .cloned()
7659            .ok_or_else(|| {
7660                anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
7661            });
7662    }
7663
7664    ordered
7665        .into_iter()
7666        .next()
7667        .ok_or_else(|| anyhow!("package had no versions"))
7668}
7669
7670fn select_v2_version(
7671    versions: &[PackageVersionDataEntry],
7672    requested_version: Option<&str>,
7673    requested_channel: Option<&str>,
7674    package_id: &str,
7675    source_name: &str,
7676) -> Result<PackageVersionDataEntry> {
7677    let mut ordered = versions.to_vec();
7678    ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
7679
7680    if let Some(channel) = requested_channel {
7681        bail!(
7682            "Channel '{channel}' cannot be matched for package '{package_id}' in pre-indexed V2 source '{source_name}' because the local source index does not include channel metadata."
7683        );
7684    }
7685
7686    if let Some(version) = requested_version {
7687        return ordered
7688            .iter()
7689            .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
7690            .cloned()
7691            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
7692    }
7693
7694    ordered
7695        .into_iter()
7696        .next()
7697        .ok_or_else(|| anyhow!("package had no V2 versions"))
7698}
7699
7700fn missing_package_version(
7701    package_id: &str,
7702    source_name: &str,
7703    requested_version: &str,
7704    requested_channel: Option<&str>,
7705) -> anyhow::Error {
7706    anyhow!(MissingPackageVersionError {
7707        package_id: package_id.to_owned(),
7708        source_name: source_name.to_owned(),
7709        requested_version: requested_version.to_owned(),
7710        requested_channel: requested_channel.map(str::to_owned),
7711    })
7712}
7713
7714fn missing_error_after_open_refresh<T>(error: anyhow::Error, refresh: &PreindexedRefreshOutcome) -> Result<T> {
7715    if let Some(refresh_error) = refresh.error.as_deref() {
7716        let missing_error = error.to_string();
7717        return Err(error).with_context(|| format!("{missing_error} The source refresh also failed: {refresh_error}"));
7718    }
7719
7720    Err(error)
7721}
7722
7723fn select_rest_version<'a>(
7724    versions: &'a [VersionKey],
7725    requested_version: Option<&str>,
7726    requested_channel: Option<&str>,
7727    package_id: &str,
7728    source_name: &str,
7729) -> Result<&'a VersionKey> {
7730    if let Some(version) = requested_version {
7731        let channel = requested_channel.unwrap_or_default();
7732        return versions
7733            .iter()
7734            .find(|candidate| {
7735                candidate.version.eq_ignore_ascii_case(version)
7736                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
7737            })
7738            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
7739    }
7740
7741    if let Some(channel) = requested_channel {
7742        return versions
7743            .iter()
7744            .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
7745            .ok_or_else(|| {
7746                anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
7747            });
7748    }
7749
7750    versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
7751}
7752
7753fn sort_versions_desc(versions: &mut [VersionKey]) {
7754    versions.sort_by(|left, right| {
7755        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
7756    });
7757}
7758
7759fn compare_version_and_channel(
7760    left_version: &str,
7761    left_channel: &str,
7762    right_version: &str,
7763    right_channel: &str,
7764) -> Ordering {
7765    compare_version(left_version, right_version).then_with(|| {
7766        left_channel
7767            .to_ascii_lowercase()
7768            .cmp(&right_channel.to_ascii_lowercase())
7769    })
7770}
7771
7772fn compare_version(left: &str, right: &str) -> Ordering {
7773    let left_parts = tokenize_version(left);
7774    let right_parts = tokenize_version(right);
7775    let max_len = left_parts.len().max(right_parts.len());
7776
7777    for index in 0..max_len {
7778        let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
7779        let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
7780        let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
7781
7782        let ordering = if let Some((left_number, right_number)) = numeric {
7783            left_number.cmp(&right_number)
7784        } else {
7785            left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
7786        };
7787
7788        if ordering != Ordering::Equal {
7789            return ordering;
7790        }
7791    }
7792
7793    Ordering::Equal
7794}
7795
7796fn tokenize_version(value: &str) -> Vec<String> {
7797    let mut parts = Vec::new();
7798    let mut current = String::new();
7799    let mut current_is_digit = None;
7800
7801    for ch in value.chars() {
7802        if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
7803            if !current.is_empty() {
7804                parts.push(std::mem::take(&mut current));
7805            }
7806            current_is_digit = None;
7807            continue;
7808        }
7809
7810        let is_digit = ch.is_ascii_digit();
7811        match current_is_digit {
7812            Some(previous) if previous != is_digit => {
7813                parts.push(std::mem::take(&mut current));
7814                current.push(ch);
7815                current_is_digit = Some(is_digit);
7816            }
7817            Some(_) => current.push(ch),
7818            None => {
7819                current.push(ch);
7820                current_is_digit = Some(is_digit);
7821            }
7822        }
7823    }
7824
7825    if !current.is_empty() {
7826        parts.push(current);
7827    }
7828
7829    parts
7830}
7831
7832fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
7833    if !hash_matches(Some(expected_hash), bytes) {
7834        bail!("downloaded file hash did not match expected SHA256");
7835    }
7836    Ok(())
7837}
7838
7839fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
7840    let Some(expected_hash) = expected_hash else {
7841        return Ok(());
7842    };
7843    if ignore_security_hash {
7844        return Ok(());
7845    }
7846
7847    if !hash_matches(Some(expected_hash), bytes) {
7848        let actual = sha256_hex(bytes);
7849        bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
7850    }
7851    Ok(())
7852}
7853
7854fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
7855    expected_hash
7856        .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
7857        .unwrap_or(true)
7858}
7859
7860fn sha256_hex(bytes: &[u8]) -> String {
7861    let digest = Sha256::digest(bytes);
7862    let mut output = String::with_capacity(digest.len() * 2);
7863    for byte in digest {
7864        output.push_str(&format!("{byte:02X}"));
7865    }
7866    output
7867}
7868
7869fn pins_db_path(app_root: &Path) -> PathBuf {
7870    if uses_packaged_layout(app_root) {
7871        app_root.join("pinning.db")
7872    } else {
7873        app_root.join("pins.db")
7874    }
7875}
7876
7877fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
7878    let mut has_current_column = false;
7879    let mut has_legacy_column = false;
7880
7881    for column_name in query_rows(conn, "PRAGMA table_info(pin)", Vec::new(), |row| row_string(row, 1))? {
7882        match column_name.as_str() {
7883            "type" => has_current_column = true,
7884            "pin_type" => has_legacy_column = true,
7885            _ => {}
7886        }
7887    }
7888
7889    Ok(if has_current_column {
7890        Some("type")
7891    } else if has_legacy_column {
7892        Some("pin_type")
7893    } else {
7894        None
7895    })
7896}
7897
7898fn decode_pin_type(pin_type_int: i64) -> PinType {
7899    match pin_type_int {
7900        4 => PinType::Blocking,
7901        3 => PinType::Gating,
7902        _ => PinType::Pinning,
7903    }
7904}
7905
7906#[cfg(windows)]
7907fn dispatch_installer(
7908    installer_path: &Path,
7909    installer_type: &str,
7910    request: &InstallRequest,
7911    manifest: &Manifest,
7912    installer: &Installer,
7913) -> Result<i32> {
7914    use std::process::Command;
7915
7916    match installer_type {
7917        "msi" | "wix" => {
7918            let mut cmd = Command::new("msiexec");
7919            cmd.args(installer_command_arguments(
7920                installer_type,
7921                request,
7922                manifest,
7923                installer_path,
7924                installer,
7925            ));
7926            let status = cmd.status().context("failed to run msiexec")?;
7927            Ok(status.code().unwrap_or(-1))
7928        }
7929        "msix" | "appx" => {
7930            let mut cmd = Command::new("powershell");
7931            cmd.arg("-NoProfile")
7932                .arg("-Command")
7933                .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
7934            let status = cmd.status().context("failed to run Add-AppxPackage")?;
7935            Ok(status.code().unwrap_or(-1))
7936        }
7937        "zip" => {
7938            // A `zip` at the top-level installer position is treated as portable when
7939            // `NestedInstallerType=portable` is specified — that's the shape every
7940            // portable in the winget catalog uses (e.g. BurntSushi.ripgrep.MSVC).
7941            // Anything else still falls through to the historical extract-to-Programs
7942            // behavior, which isn't really correct but at least doesn't regress.
7943            if installer
7944                .nested_installer_type
7945                .as_deref()
7946                .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
7947            {
7948                return install_portable(installer_path, request, manifest, installer);
7949            }
7950            let target = dirs::data_local_dir()
7951                .unwrap_or_else(|| PathBuf::from("."))
7952                .join("Programs");
7953            fs::create_dir_all(&target)?;
7954            let file = fs::File::open(installer_path)?;
7955            let mut archive = ZipArchive::new(file)?;
7956            archive.extract(&target)?;
7957            Ok(0)
7958        }
7959        "portable" => install_portable(installer_path, request, manifest, installer),
7960        // exe, inno, nullsoft, burn, etc.
7961        _ => {
7962            let mut cmd = Command::new(installer_path);
7963            cmd.args(installer_command_arguments(
7964                installer_type,
7965                request,
7966                manifest,
7967                installer_path,
7968                installer,
7969            ));
7970            let status = cmd.status().context("failed to run installer")?;
7971            Ok(status.code().unwrap_or(-1))
7972        }
7973    }
7974}
7975
7976#[cfg(not(windows))]
7977fn dispatch_installer(
7978    _installer_path: &Path,
7979    _installer_type: &str,
7980    _request: &InstallRequest,
7981    _manifest: &Manifest,
7982    _installer: &Installer,
7983) -> Result<i32> {
7984    bail!("Installing packages is only supported on Windows")
7985}
7986
7987#[cfg(any(windows, test))]
7988fn installer_command_arguments(
7989    installer_type: &str,
7990    request: &InstallRequest,
7991    _manifest: &Manifest,
7992    installer_path: &Path,
7993    installer: &Installer,
7994) -> Vec<String> {
7995    if let Some(override_args) = request.override_args.as_deref() {
7996        return split_installer_switches(override_args);
7997    }
7998
7999    let mut args = Vec::new();
8000    match installer_type {
8001        "msi" | "wix" => {
8002            args.push("/i".to_owned());
8003            args.push(installer_path.display().to_string());
8004        }
8005        "msix" | "appx" | "zip" => return Vec::new(),
8006        _ => {}
8007    }
8008
8009    let configured = match request.mode {
8010        InstallerMode::Interactive => installer.switches.interactive.as_deref(),
8011        InstallerMode::SilentWithProgress => installer
8012            .switches
8013            .silent_with_progress
8014            .as_deref()
8015            .or(installer.switches.silent.as_deref()),
8016        InstallerMode::Silent => installer
8017            .switches
8018            .silent
8019            .as_deref()
8020            .or(installer.switches.silent_with_progress.as_deref()),
8021    };
8022
8023    if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
8024        args.extend(split_installer_switches(value));
8025    } else {
8026        args.extend(default_installer_arguments(installer_type, request.mode));
8027    }
8028
8029    append_switches(
8030        &mut args,
8031        resolve_template_switch(
8032            installer.switches.log.as_deref(),
8033            default_log_switch(installer_type),
8034            request.log_path.as_ref().map(|path| path.display().to_string()),
8035            "<LOGPATH>",
8036        ),
8037    );
8038    append_switches(&mut args, installer.switches.custom.clone());
8039    append_switches(&mut args, request.custom.clone());
8040    append_switches(
8041        &mut args,
8042        resolve_template_switch(
8043            installer.switches.install_location.as_deref(),
8044            default_install_location_switch(installer_type),
8045            request.install_location.clone(),
8046            "<INSTALLPATH>",
8047        ),
8048    );
8049    args
8050}
8051
8052#[cfg(any(windows, test))]
8053fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
8054    match mode {
8055        InstallerMode::Interactive => Vec::new(),
8056        InstallerMode::SilentWithProgress => match installer_type {
8057            "inno" => vec![
8058                "/SP-".to_owned(),
8059                "/SILENT".to_owned(),
8060                "/SUPPRESSMSGBOXES".to_owned(),
8061                "/NORESTART".to_owned(),
8062            ],
8063            "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
8064            "nullsoft" | "nsis" => vec!["/S".to_owned()],
8065            _ => vec!["/SILENT".to_owned()],
8066        },
8067        InstallerMode::Silent => match installer_type {
8068            "inno" => vec![
8069                "/SP-".to_owned(),
8070                "/VERYSILENT".to_owned(),
8071                "/SUPPRESSMSGBOXES".to_owned(),
8072                "/NORESTART".to_owned(),
8073            ],
8074            "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
8075            "nullsoft" | "nsis" => vec!["/S".to_owned()],
8076            _ => vec!["/S".to_owned()],
8077        },
8078    }
8079}
8080
8081#[cfg(any(windows, test))]
8082fn default_log_switch(installer_type: &str) -> Option<&'static str> {
8083    match installer_type {
8084        "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
8085        "inno" => Some("/LOG=\"<LOGPATH>\""),
8086        _ => None,
8087    }
8088}
8089
8090#[cfg(any(windows, test))]
8091fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
8092    match installer_type {
8093        "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
8094        "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
8095        "inno" => Some("/DIR=\"<INSTALLPATH>\""),
8096        _ => None,
8097    }
8098}
8099
8100#[cfg(any(windows, test))]
8101fn resolve_template_switch(
8102    manifest_value: Option<&str>,
8103    fallback: Option<&str>,
8104    replacement: Option<String>,
8105    token: &str,
8106) -> Option<String> {
8107    let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
8108    let replacement = replacement?;
8109    Some(template.replace(token, &replacement))
8110}
8111
8112#[cfg(any(windows, test))]
8113fn append_switches(args: &mut Vec<String>, value: Option<String>) {
8114    if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
8115        args.extend(split_installer_switches(&value));
8116    }
8117}
8118
8119#[cfg(any(windows, test))]
8120fn split_installer_switches(value: &str) -> Vec<String> {
8121    let mut args = Vec::new();
8122    let mut current = String::new();
8123    let mut in_quotes = false;
8124
8125    for ch in value.chars() {
8126        match ch {
8127            '"' => in_quotes = !in_quotes,
8128            c if c.is_whitespace() && !in_quotes => {
8129                if !current.is_empty() {
8130                    args.push(std::mem::take(&mut current));
8131                }
8132            }
8133            _ => current.push(ch),
8134        }
8135    }
8136
8137    if !current.is_empty() {
8138        args.push(current);
8139    }
8140
8141    args
8142}
8143
8144#[cfg(windows)]
8145fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
8146    if request.purge && request.preserve {
8147        bail!("--purge and --preserve cannot be used together.");
8148    }
8149
8150    if installed
8151        .installer_category
8152        .as_deref()
8153        .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
8154    {
8155        return uninstall_portable(installed, request);
8156    }
8157
8158    if (request.purge || request.preserve) && !request.force {
8159        bail!("--purge and --preserve are currently only supported for portable packages.");
8160    }
8161
8162    if let Some(exit_code) = try_uninstall_arp(installed, request)? {
8163        return Ok(exit_code);
8164    }
8165
8166    if let Some(exit_code) = try_uninstall_msix(installed)? {
8167        return Ok(exit_code);
8168    }
8169
8170    bail!(
8171        "No uninstall command found for installed package '{}' ({})",
8172        installed.name,
8173        installed.local_id
8174    );
8175}
8176
8177#[cfg(windows)]
8178fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
8179    use std::os::windows::process::CommandExt;
8180    use std::process::Command;
8181
8182    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
8183    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8184
8185    let arp_paths = [
8186        r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
8187        r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
8188    ];
8189
8190    for root in [&hklm, &hkcu] {
8191        for arp_path in &arp_paths {
8192            if let Ok(key) = root.open_subkey(arp_path) {
8193                for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
8194                    if let Ok(subkey) = key.open_subkey(&subkey_name) {
8195                        let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
8196                        let product_code = subkey.get_value::<String, _>("ProductCode").ok();
8197                        if !registry_entry_matches_installed_package(
8198                            &subkey_name,
8199                            &display_name,
8200                            product_code.as_deref(),
8201                            installed,
8202                        ) {
8203                            continue;
8204                        }
8205
8206                        if let Some(exit_code) =
8207                            try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
8208                        {
8209                            return Ok(Some(exit_code));
8210                        }
8211
8212                        let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
8213                        let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
8214                            subkey.get_value("UninstallString").ok()
8215                        } else {
8216                            quiet_uninstall_cmd
8217                                .clone()
8218                                .or_else(|| subkey.get_value("UninstallString").ok())
8219                        }
8220                        .context("No uninstall command found in registry")?;
8221
8222                        if is_winget_uninstall_command(&uninstall_cmd)
8223                            && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
8224                        {
8225                            return Ok(Some(exit_code));
8226                        }
8227
8228                        let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
8229                        let uninstall_line = build_uninstall_command_with_mode(
8230                            &uninstall_cmd,
8231                            request.mode,
8232                            quiet_uninstall_cmd.is_some(),
8233                            log_path.as_deref(),
8234                        );
8235                        // `UninstallString` is a complete command line and may already contain
8236                        // its own quotes (e.g. `"C:\path with spaces\unins.exe"`). Passing it
8237                        // through Command::arg causes Rust's CRT-style quoting to backslash-escape
8238                        // those embedded quotes (`\"...\"`), which cmd.exe does not understand and
8239                        // rejects with "is not recognized as an internal or external command".
8240                        // Use `cmd /S /C "<line>"`: `/S` makes cmd strip only the outermost pair
8241                        // of quotes, leaving the embedded quotes intact. `raw_arg` bypasses Rust's
8242                        // automatic quoting so we can feed cmd the literal command line.
8243                        let mut cmd = Command::new("cmd");
8244                        cmd.raw_arg("/S /C");
8245                        cmd.raw_arg(format!("\"{uninstall_line}\""));
8246                        let status = cmd.status().context("failed to run uninstaller")?;
8247                        return Ok(Some(status.code().unwrap_or(-1)));
8248                    }
8249                }
8250            }
8251        }
8252    }
8253
8254    Ok(None)
8255}
8256
8257#[cfg(windows)]
8258fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
8259    use std::process::Command;
8260
8261    if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
8262        return Ok(None);
8263    }
8264
8265    let package_full_name = installed.local_id.strip_prefix("MSIX\\");
8266    let mut cmd = Command::new("powershell");
8267    cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
8268        package_full_name,
8269        &installed.package_family_names,
8270    ));
8271    let status = cmd.status().context("failed to run Remove-AppxPackage")?;
8272    Ok(Some(status.code().unwrap_or(-1)))
8273}
8274
8275#[cfg(windows)]
8276fn arp_subkey_name(local_id: &str) -> Option<&str> {
8277    local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
8278}
8279
8280#[cfg(windows)]
8281fn registry_entry_matches_installed_package(
8282    subkey_name: &str,
8283    display_name: &str,
8284    product_code: Option<&str>,
8285    installed: &ListMatch,
8286) -> bool {
8287    if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
8288        && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
8289    {
8290        return true;
8291    }
8292
8293    if installed.product_codes.iter().any(|code| {
8294        code.eq_ignore_ascii_case(subkey_name)
8295            || product_code
8296                .map(|value| code.eq_ignore_ascii_case(value))
8297                .unwrap_or(false)
8298    }) {
8299        return true;
8300    }
8301
8302    display_name.eq_ignore_ascii_case(&installed.name)
8303}
8304
8305#[cfg(all(windows, test))]
8306fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
8307    build_uninstall_command_with_mode(
8308        uninstall_cmd,
8309        if silent {
8310            InstallerMode::Silent
8311        } else {
8312            InstallerMode::Interactive
8313        },
8314        has_quiet_command,
8315        None,
8316    )
8317}
8318
8319#[cfg(windows)]
8320fn build_uninstall_command_with_mode(
8321    uninstall_cmd: &str,
8322    mode: InstallerMode,
8323    has_quiet_command: bool,
8324    log_path: Option<&str>,
8325) -> String {
8326    let uninstall_cmd = log_path
8327        .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
8328        .unwrap_or_else(|| uninstall_cmd.to_owned());
8329    if mode == InstallerMode::Interactive || has_quiet_command {
8330        return uninstall_cmd;
8331    }
8332
8333    let lower = uninstall_cmd.to_ascii_lowercase();
8334    if is_winget_uninstall_command_lower(&lower) {
8335        return uninstall_cmd;
8336    }
8337    if lower.contains("/quiet")
8338        || lower.contains("/passive")
8339        || lower.contains("/verysilent")
8340        || lower.contains("/silent")
8341        || lower.contains(" /s")
8342    {
8343        return uninstall_cmd;
8344    }
8345
8346    format!("{uninstall_cmd} /S")
8347}
8348
8349#[cfg(windows)]
8350fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
8351    let Some(args) = build_winget_uninstall_arguments(installed, request) else {
8352        return Ok(None);
8353    };
8354
8355    let exit_code = run_winget_uninstall(&args)?;
8356    if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
8357        let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
8358            return Ok(Some(exit_code));
8359        };
8360
8361        return Ok(Some(run_winget_uninstall(&args_without_scope)?));
8362    }
8363
8364    Ok(Some(exit_code))
8365}
8366
8367#[cfg(windows)]
8368fn run_winget_uninstall(args: &[String]) -> Result<i32> {
8369    use std::process::Command;
8370
8371    let status = Command::new("winget")
8372        .args(args)
8373        .status()
8374        .context("failed to run delegated winget uninstall")?;
8375    Ok(status.code().unwrap_or(-1))
8376}
8377
8378#[cfg(windows)]
8379fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
8380    build_winget_uninstall_arguments_with_scope(installed, request, true)
8381}
8382
8383#[cfg(windows)]
8384fn build_winget_uninstall_arguments_with_scope(
8385    installed: &ListMatch,
8386    request: &UninstallRequest,
8387    include_scope: bool,
8388) -> Option<Vec<String>> {
8389    if installed.id.eq_ignore_ascii_case(&installed.local_id) {
8390        return None;
8391    }
8392
8393    let mut args = vec![
8394        "uninstall".to_owned(),
8395        "--id".to_owned(),
8396        installed.id.clone(),
8397        "--exact".to_owned(),
8398        "--disable-interactivity".to_owned(),
8399    ];
8400
8401    if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
8402        args.push("--source".to_owned());
8403        args.push(source_name.to_owned());
8404    }
8405
8406    if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
8407        args.push("--scope".to_owned());
8408        args.push(scope.to_owned());
8409    }
8410
8411    if request.mode == InstallerMode::Silent {
8412        args.push("--silent".to_owned());
8413    }
8414
8415    Some(args)
8416}
8417
8418#[cfg(windows)]
8419fn is_winget_uninstall_command(command: &str) -> bool {
8420    is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
8421}
8422
8423#[cfg(windows)]
8424fn is_winget_uninstall_command_lower(command: &str) -> bool {
8425    command.contains("winget uninstall") || command.contains("winget.exe uninstall")
8426}
8427
8428#[cfg(windows)]
8429fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
8430    let full_name_literal = package_full_name
8431        .map(|value| format!("'{}'", value.replace('\'', "''")))
8432        .unwrap_or_else(|| "$null".to_owned());
8433    let family_names_literal = if package_family_names.is_empty() {
8434        "@()".to_owned()
8435    } else {
8436        format!(
8437            "@({})",
8438            package_family_names
8439                .iter()
8440                .map(|value| format!("'{}'", value.replace('\'', "''")))
8441                .collect::<Vec<_>>()
8442                .join(", ")
8443        )
8444    };
8445
8446    format!(
8447        "$fullName = {full_name_literal}; \
8448         $familyNames = {family_names_literal}; \
8449         $targets = Get-AppxPackage | Where-Object {{ \
8450         (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
8451         ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
8452         if (-not $targets) {{ exit 1 }}; \
8453         $targets | Remove-AppxPackage"
8454    )
8455}
8456
8457#[cfg(windows)]
8458fn try_run_msi_uninstall(
8459    installed: &ListMatch,
8460    subkey_name: &str,
8461    product_code: Option<&str>,
8462    request: &UninstallRequest,
8463) -> Result<Option<i32>> {
8464    use std::process::Command;
8465
8466    let uninstall_code = installed
8467        .product_codes
8468        .iter()
8469        .map(String::as_str)
8470        .chain(product_code)
8471        .chain(Some(subkey_name))
8472        .find(|value| is_product_code_like(value));
8473
8474    let Some(uninstall_code) = uninstall_code else {
8475        return Ok(None);
8476    };
8477
8478    let mut cmd = Command::new("msiexec");
8479    cmd.arg("/x").arg(uninstall_code);
8480    match request.mode {
8481        InstallerMode::Interactive => {}
8482        InstallerMode::SilentWithProgress => {
8483            cmd.arg("/passive").arg("/norestart");
8484        }
8485        InstallerMode::Silent => {
8486            cmd.arg("/quiet").arg("/norestart");
8487        }
8488    }
8489    if let Some(log_path) = &request.log_path {
8490        cmd.arg("/log").arg(log_path);
8491    }
8492
8493    let status = cmd.status().context("failed to run msiexec uninstall")?;
8494    Ok(Some(status.code().unwrap_or(-1)))
8495}
8496
8497#[cfg(windows)]
8498fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
8499    // Read PortableSymlinkFullPath from the ARP entry *before* we touch the
8500    // registry, so we can remove winget-created shims in Links\ if any. Without
8501    // this, a portable that winget originally installed (with a
8502    // PortableCommandAlias set in its manifest) would leave a dangling symlink
8503    // in %LOCALAPPDATA%\Microsoft\WinGet\Links\ once pinget uninstalls it.
8504    let known_symlink = read_portable_symlink_full_path(installed);
8505
8506    let Some(location) = installed.install_location.as_deref() else {
8507        if request.force {
8508            try_remove_portable_symlinks(installed, &known_symlink, None);
8509            try_remove_portable_registry_entry(installed);
8510            return Ok(0);
8511        }
8512        bail!(
8513            "Portable package '{}' does not expose an install location.",
8514            installed.name
8515        );
8516    };
8517
8518    if request.preserve {
8519        return Ok(0);
8520    }
8521
8522    let path = Path::new(location);
8523    let removed = if path.is_dir() {
8524        fs::remove_dir_all(path)?;
8525        true
8526    } else if path.is_file() {
8527        fs::remove_file(path)?;
8528        true
8529    } else {
8530        false
8531    };
8532
8533    if removed || request.force {
8534        // Once the files are gone (or the user forced past missing files), drop
8535        // the symlink shim and ARP entry too so the package fully disappears.
8536        try_remove_portable_symlinks(installed, &known_symlink, Some(path));
8537        try_remove_portable_registry_entry(installed);
8538        return Ok(0);
8539    }
8540
8541    bail!("Portable package location not found: {location}")
8542}
8543
8544/// Reads `PortableSymlinkFullPath` from the ARP subkey backing `installed`
8545/// when present. This value is recorded for portable installs that created a
8546/// `Links\<alias>.exe` symlink, including pinget-installed entries when
8547/// `install_portable` created that link. Entries without a created/recorded
8548/// portable symlink return `None`.
8549#[cfg(windows)]
8550fn read_portable_symlink_full_path(installed: &ListMatch) -> Option<String> {
8551    const ARP_PREFIX: &str = r"ARP\";
8552    if let Some(rest) = installed.local_id.strip_prefix(ARP_PREFIX) {
8553        let mut parts = rest.splitn(3, '\\');
8554        let scope = parts.next().unwrap_or("");
8555        let _arch = parts.next();
8556        let subkey_name = parts.next().unwrap_or("");
8557        if subkey_name.is_empty() {
8558            return None;
8559        }
8560        let hive = if scope.eq_ignore_ascii_case("User") {
8561            RegKey::predef(HKEY_CURRENT_USER)
8562        } else {
8563            RegKey::predef(HKEY_LOCAL_MACHINE)
8564        };
8565        let subkey = hive
8566            .open_subkey(format!(
8567                r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"
8568            ))
8569            .ok()?;
8570        return read_reg_string(&subkey, "PortableSymlinkFullPath");
8571    }
8572    None
8573}
8574
8575/// Removes the symlink shims winget would have created for this portable.
8576/// Tries (1) the exact path from `PortableSymlinkFullPath` if known, and
8577/// (2) any symlink in `%LOCALAPPDATA%\Microsoft\WinGet\Links\` whose target
8578/// resolves under `install_location` (defensive sweep for shims winget made
8579/// but didn't surface in the registry). Best-effort: failures are swallowed.
8580#[cfg(windows)]
8581fn try_remove_portable_symlinks(
8582    _installed: &ListMatch,
8583    known_symlink: &Option<String>,
8584    install_location: Option<&Path>,
8585) {
8586    if let Some(path) = known_symlink.as_deref() {
8587        let _ = fs::remove_file(path);
8588    }
8589
8590    // Defensive sweep: walk Links\ and remove shims whose stored target was
8591    // inside (or equal to) install_location. By this point install_location is
8592    // already deleted, so we compare the symlink's stored target string (which
8593    // the filesystem retains even after the target itself is gone).
8594    let Some(install_dir) = install_location else { return };
8595
8596    let Some(local) = dirs::data_local_dir() else { return };
8597    let links_root = local.join("Microsoft").join("WinGet").join("Links");
8598    let Ok(entries) = fs::read_dir(&links_root) else { return };
8599    for entry in entries.flatten() {
8600        let path = entry.path();
8601        let Ok(target) = fs::read_link(&path) else { continue };
8602        if path_starts_with_case_insensitive(&target, install_dir) {
8603            let _ = fs::remove_file(&path);
8604        }
8605    }
8606}
8607
8608/// Case-insensitive, component-aware path-prefix check for Windows. A raw
8609/// `str::starts_with` on the path strings would let `C:\Foo` falsely match
8610/// `C:\Foobar`; iterating path components avoids that. Used by the Links\
8611/// sweep so an uninstall of one portable can't accidentally delete shims that
8612/// belong to a different portable whose install dir happens to share a
8613/// string-prefix.
8614#[cfg(windows)]
8615fn path_starts_with_case_insensitive(child: &Path, parent: &Path) -> bool {
8616    let lower_components = |p: &Path| -> Vec<String> {
8617        p.components()
8618            .map(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase())
8619            .collect()
8620    };
8621    let parent_components = lower_components(parent);
8622    let child_components = lower_components(child);
8623    if parent_components.is_empty() || parent_components.len() > child_components.len() {
8624        return false;
8625    }
8626    parent_components
8627        .iter()
8628        .zip(child_components.iter())
8629        .all(|(a, b)| a == b)
8630}
8631
8632/// Best-effort removal of the HKCU/HKLM ARP subkey backing a portable entry.
8633/// We match on local_id (`ARP\<scope>\<arch>\<subkey>`) when present so we
8634/// delete the exact key the list view surfaced; otherwise we walk the standard
8635/// Uninstall path and match on WinGetPackageIdentifier. Failures are logged via
8636/// the returned warning chain in callers — we never bubble a registry-cleanup
8637/// failure as a hard error because the file deletion already succeeded.
8638#[cfg(windows)]
8639fn try_remove_portable_registry_entry(installed: &ListMatch) {
8640    // Collect (is_hkcu, subkey_path) pairs so we can re-open hives at delete
8641    // time instead of holding clones of RegKey (which doesn't implement Clone).
8642    let candidates: Vec<(bool, String)> = if let Some(rest) = installed.local_id.strip_prefix(r"ARP\") {
8643        // local_id format is `ARP\<scope>\<arch>\<subkey>` — pick the right hive/path.
8644        let mut parts = rest.splitn(3, '\\');
8645        let scope = parts.next().unwrap_or("");
8646        let _arch = parts.next();
8647        let subkey_name = parts.next().unwrap_or("");
8648        if subkey_name.is_empty() {
8649            return;
8650        }
8651        let is_hkcu = scope.eq_ignore_ascii_case("User");
8652        vec![(
8653            is_hkcu,
8654            format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"),
8655        )]
8656    } else {
8657        // Fall back to scanning by WinGetPackageIdentifier.
8658        let mut found = Vec::new();
8659        for (is_hkcu, hive) in [
8660            (true, RegKey::predef(HKEY_CURRENT_USER)),
8661            (false, RegKey::predef(HKEY_LOCAL_MACHINE)),
8662        ] {
8663            let Ok(uninstall) = hive.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") else {
8664                continue;
8665            };
8666            for name in uninstall.enum_keys().flatten() {
8667                let Ok(subkey) = uninstall.open_subkey(&name) else {
8668                    continue;
8669                };
8670                if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() == Some(installed.id.as_str()) {
8671                    found.push((
8672                        is_hkcu,
8673                        format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{name}"),
8674                    ));
8675                }
8676            }
8677        }
8678        found
8679    };
8680
8681    for (is_hkcu, path) in candidates {
8682        let hive = if is_hkcu {
8683            RegKey::predef(HKEY_CURRENT_USER)
8684        } else {
8685            RegKey::predef(HKEY_LOCAL_MACHINE)
8686        };
8687        let _ = hive.delete_subkey_all(&path);
8688    }
8689}
8690
8691#[cfg(windows)]
8692fn is_product_code_like(value: &str) -> bool {
8693    value.starts_with('{') && value.ends_with('}')
8694}
8695
8696#[cfg(not(windows))]
8697fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
8698    bail!("Uninstalling packages is only supported on Windows")
8699}
8700
8701/// Installs (or upgrades) a portable WinGet package.
8702///
8703/// Handles both `InstallerType: portable` (single binary download) and
8704/// `InstallerType: zip` with `NestedInstallerType: portable` (zip containing
8705/// the binary at a relative path), which is the shape every portable in the
8706/// community winget catalog uses today.
8707///
8708/// Behavior matches winget's portable workflow closely enough that the resulting
8709/// HKCU ARP entry is recognized by `winget list` and by pinget's own list view:
8710/// - Resolves the target install directory in this priority order:
8711///   1. `request.install_location` if provided (passed for upgrades)
8712///   2. Existing `InstallLocation` from the registry ARP entry (so upgrades
8713///      with no explicit location still preserve the user's custom path)
8714///   3. WinGet's default user portable root, `%LOCALAPPDATA%\Microsoft\WinGet
8715///      \Packages\<PackageId>_<SourceId>`
8716/// - If an existing pinget-owned install lives in that directory
8717///   (`InstallDirectoryCreated=1`), cleans its contents before extracting the
8718///   new version so leftover files from older versions don't pile up.
8719/// - Extracts the zip (or copies the standalone binary), then writes the ARP
8720///   registry entry that `winget list` and pinget's `collect_uninstall_view`
8721///   both read from.
8722#[cfg(windows)]
8723fn install_portable(
8724    installer_path: &Path,
8725    request: &InstallRequest,
8726    manifest: &Manifest,
8727    installer: &Installer,
8728) -> Result<i32> {
8729    let source_identifier = portable_source_identifier(&request.query);
8730    let existing_entry = read_existing_portable_entry(&manifest.id);
8731    // Reuse the existing subkey name on upgrade so we don't orphan the prior
8732    // entry by writing to a different one.
8733    let subkey_name = existing_entry
8734        .as_ref()
8735        .map(|entry| entry.subkey_name.clone())
8736        .unwrap_or_else(|| portable_subkey_name(&manifest.id, &source_identifier));
8737
8738    let target_dir = resolve_portable_install_location(request, &existing_entry, &subkey_name);
8739
8740    // If we created this directory on a previous install, wipe it clean so the
8741    // new version doesn't co-exist with leftovers from the previous one. We do
8742    // not touch a directory we don't own (InstallDirectoryCreated != 1), to
8743    // avoid stomping user data.
8744    let prev_dir_created = existing_entry
8745        .as_ref()
8746        .and_then(|entry| entry.install_directory_created)
8747        == Some(1);
8748    let dir_existed = target_dir.is_dir();
8749    if prev_dir_created && dir_existed {
8750        clean_directory_contents(&target_dir).with_context(|| {
8751            format!(
8752                "failed to clean existing portable install directory: {}",
8753                target_dir.display()
8754            )
8755        })?;
8756    }
8757
8758    fs::create_dir_all(&target_dir)
8759        .with_context(|| format!("failed to create portable install directory: {}", target_dir.display()))?;
8760    // The "we created it" flag stays sticky across upgrades — if a previous
8761    // install marked it created, it still counts as created by us even when
8762    // the dir already existed this round.
8763    let install_directory_created = !dir_existed || prev_dir_created;
8764
8765    let installer_type = installer.installer_type.as_deref().unwrap_or("");
8766    let portable_target_full_path: PathBuf = if installer_type.eq_ignore_ascii_case("zip") {
8767        let file = fs::File::open(installer_path)
8768            .with_context(|| format!("failed to open installer zip: {}", installer_path.display()))?;
8769        let mut archive = ZipArchive::new(file).context("failed to read installer zip")?;
8770        archive
8771            .extract(&target_dir)
8772            .with_context(|| format!("failed to extract zip into: {}", target_dir.display()))?;
8773        // For nested-portable zips the binary lives at the RelativeFilePath the
8774        // manifest declares. We record the first one as PortableTargetFullPath so
8775        // winget's portable uninstall workflow can identify which file to remove.
8776        installer
8777            .nested_installer_files
8778            .first()
8779            .map(|file| target_dir.join(&file.relative_file_path))
8780            .unwrap_or_else(|| target_dir.clone())
8781    } else {
8782        // Standalone portable: copy the downloaded file into target_dir using
8783        // its original filename. winget would optionally rename to the
8784        // PortableCommandAlias; pinget keeps the original name for now.
8785        let basename = installer_path
8786            .file_name()
8787            .ok_or_else(|| anyhow!("portable installer has no filename"))?;
8788        let copied = target_dir.join(basename);
8789        fs::copy(installer_path, &copied)
8790            .with_context(|| format!("failed to copy portable binary into: {}", target_dir.display()))?;
8791        copied
8792    };
8793
8794    // Create the Links\<alias>.exe shim so users can invoke the portable from
8795    // any shell, matching winget's portable workflow. Failures are non-fatal —
8796    // when the user lacks SeCreateSymbolicLink (no Developer Mode, not admin)
8797    // we still leave the package installed at install_location.
8798    let alias = determine_portable_alias(installer);
8799    let symlink_full_path = match alias.as_deref() {
8800        Some(alias) if !alias.is_empty() => try_create_portable_symlink(&portable_target_full_path, alias),
8801        _ => None,
8802    };
8803
8804    // Ensure %LOCALAPPDATA%\Microsoft\WinGet\Links is on user PATH so the
8805    // symlink we just created is resolvable from new shells. Tracks whether we
8806    // added it this round so uninstall can decide whether to take it back out.
8807    let added_to_path = if symlink_full_path.is_some() {
8808        try_add_links_dir_to_user_path()
8809    } else {
8810        false
8811    };
8812
8813    write_portable_arp_entry(&PortableArpEntry {
8814        subkey_name: &subkey_name,
8815        install_location: &target_dir,
8816        portable_target_full_path: &portable_target_full_path,
8817        portable_symlink_full_path: symlink_full_path.as_deref(),
8818        install_directory_created,
8819        added_to_path,
8820        source_identifier: &source_identifier,
8821        manifest,
8822    })?;
8823
8824    Ok(0)
8825}
8826
8827#[cfg(windows)]
8828struct PortableArpEntry<'a> {
8829    subkey_name: &'a str,
8830    install_location: &'a Path,
8831    portable_target_full_path: &'a Path,
8832    portable_symlink_full_path: Option<&'a Path>,
8833    install_directory_created: bool,
8834    added_to_path: bool,
8835    source_identifier: &'a str,
8836    manifest: &'a Manifest,
8837}
8838
8839/// Picks the portable command alias from the manifest. Mirrors winget's
8840/// resolution: nested-installer files first (used by zip+portable manifests),
8841/// then the top-level `Commands` field (used by standalone portable manifests).
8842/// Returns None when the manifest doesn't declare a command — the package
8843/// still installs, just without a Links\ shim.
8844#[cfg(windows)]
8845fn determine_portable_alias(installer: &Installer) -> Option<String> {
8846    installer
8847        .nested_installer_files
8848        .iter()
8849        .find_map(|file| file.portable_command_alias.clone())
8850        .or_else(|| installer.commands.first().cloned())
8851}
8852
8853#[cfg(windows)]
8854fn winget_links_dir() -> PathBuf {
8855    dirs::data_local_dir()
8856        .unwrap_or_else(|| PathBuf::from("."))
8857        .join("Microsoft")
8858        .join("WinGet")
8859        .join("Links")
8860}
8861
8862/// Creates (or replaces) `Links\<alias>.exe` as a file symlink pointing at the
8863/// portable's binary. Returns the link path on success. Returns None and logs
8864/// a warning if the user can't create symlinks (no Developer Mode, not admin)
8865/// so the install can still complete without the shim.
8866#[cfg(windows)]
8867fn try_create_portable_symlink(target: &Path, alias: &str) -> Option<PathBuf> {
8868    use std::os::windows::fs::symlink_file;
8869    let links_dir = winget_links_dir();
8870    if fs::create_dir_all(&links_dir).is_err() {
8871        return None;
8872    }
8873    let link_path = links_dir.join(format!("{alias}.exe"));
8874
8875    // Replace any prior link/file at the path so upgrades repoint to the new
8876    // binary instead of leaving a stale symlink behind.
8877    if link_path.symlink_metadata().is_ok() {
8878        let _ = fs::remove_file(&link_path);
8879    }
8880
8881    // Symlink creation fails with ERROR_PRIVILEGE_NOT_HELD (1314) when the
8882    // process lacks SeCreateSymbolicLink (no Developer Mode, not admin). Treat
8883    // that — and any other I/O failure — as non-fatal: the package files are
8884    // already in place, just without a Links\ shim.
8885    symlink_file(target, &link_path).ok().map(|()| link_path)
8886}
8887
8888/// Adds `Links\` to the user's HKCU\Environment\Path if not already present,
8889/// then broadcasts WM_SETTINGCHANGE so explorer-spawned shells pick up the
8890/// new PATH without a logoff. Returns true if we actually appended PATH this
8891/// call — callers should write `InstallDirectoryAddedToPath` accordingly so a
8892/// follow-up uninstall doesn't take PATH back out for other portables.
8893#[cfg(windows)]
8894fn try_add_links_dir_to_user_path() -> bool {
8895    let links_dir = winget_links_dir();
8896    let links_str = links_dir.to_string_lossy().to_string();
8897    let normalized_links = links_str.to_ascii_lowercase();
8898
8899    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8900    let Ok(env_key) = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) else {
8901        return false;
8902    };
8903    let existing: String = env_key.get_value("Path").unwrap_or_default();
8904    let already_present = existing
8905        .split(';')
8906        .any(|component| component.trim().to_ascii_lowercase() == normalized_links);
8907    if already_present {
8908        return false;
8909    }
8910
8911    let new_path = if existing.is_empty() {
8912        links_str
8913    } else if existing.ends_with(';') {
8914        format!("{existing}{links_str}")
8915    } else {
8916        format!("{existing};{links_str}")
8917    };
8918
8919    if env_key.set_value("Path", &new_path).is_err() {
8920        return false;
8921    }
8922
8923    broadcast_environment_change();
8924    true
8925}
8926
8927#[cfg(windows)]
8928fn broadcast_environment_change() {
8929    use std::ffi::OsStr;
8930    use std::os::windows::ffi::OsStrExt;
8931
8932    use windows_sys::Win32::UI::WindowsAndMessaging::{
8933        HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
8934    };
8935
8936    let param: Vec<u16> = OsStr::new("Environment")
8937        .encode_wide()
8938        .chain(std::iter::once(0))
8939        .collect();
8940    // SAFETY: SendMessageTimeoutW only reads `param` for the duration of the
8941    // call (it copies the string before returning), and `result` is a stack
8942    // local we provide. No aliasing or lifetime issues.
8943    unsafe {
8944        let mut result: usize = 0;
8945        SendMessageTimeoutW(
8946            HWND_BROADCAST,
8947            WM_SETTINGCHANGE,
8948            0usize,
8949            param.as_ptr() as isize,
8950            SMTO_ABORTIFHUNG,
8951            5000,
8952            &mut result,
8953        );
8954    }
8955}
8956
8957/// Source identifier embedded in the ARP subkey name. We mirror winget for
8958/// the community repo so winget's UninstallString can resolve the subkey and
8959/// `winget list` still finds the package; everything else uses the source
8960/// name verbatim.
8961#[cfg(windows)]
8962fn portable_source_identifier(query: &PackageQuery) -> String {
8963    match query.source.as_deref() {
8964        Some(name) if name.eq_ignore_ascii_case("winget") => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
8965        Some(name) if !name.is_empty() => name.to_owned(),
8966        _ => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
8967    }
8968}
8969
8970#[cfg(windows)]
8971fn portable_subkey_name(package_id: &str, source_identifier: &str) -> String {
8972    format!("{package_id}_{source_identifier}")
8973}
8974
8975#[cfg(windows)]
8976struct ExistingPortableEntry {
8977    install_location: Option<PathBuf>,
8978    install_directory_created: Option<u32>,
8979    subkey_name: String,
8980}
8981
8982#[cfg(windows)]
8983fn read_existing_portable_entry(package_id: &str) -> Option<ExistingPortableEntry> {
8984    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8985    let uninstall = hkcu
8986        .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
8987        .ok()?;
8988    for name in uninstall.enum_keys().flatten() {
8989        let Ok(subkey) = uninstall.open_subkey(&name) else {
8990            continue;
8991        };
8992        if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() != Some(package_id) {
8993            continue;
8994        }
8995        if !read_reg_string(&subkey, "WinGetInstallerType")
8996            .as_deref()
8997            .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
8998        {
8999            continue;
9000        }
9001        return Some(ExistingPortableEntry {
9002            install_location: read_reg_string(&subkey, "InstallLocation").map(PathBuf::from),
9003            install_directory_created: read_reg_dword(&subkey, "InstallDirectoryCreated"),
9004            subkey_name: name,
9005        });
9006    }
9007    None
9008}
9009
9010#[cfg(windows)]
9011fn resolve_portable_install_location(
9012    request: &InstallRequest,
9013    existing: &Option<ExistingPortableEntry>,
9014    subkey_name: &str,
9015) -> PathBuf {
9016    resolve_portable_install_location_pure(
9017        request.install_location.as_deref(),
9018        existing
9019            .as_ref()
9020            .and_then(|e| e.install_location.as_deref())
9021            .and_then(Path::to_str),
9022        subkey_name,
9023        &default_user_portable_root(),
9024    )
9025}
9026
9027/// Pure resolution rule for the portable install directory. Priority:
9028/// `request_location` (CLI override) → `existing_location` (so upgrades
9029/// preserve a winget-installed location) → default user portable root joined
9030/// with the package's ARP subkey name.
9031#[cfg(windows)]
9032fn resolve_portable_install_location_pure(
9033    request_location: Option<&str>,
9034    existing_location: Option<&str>,
9035    subkey_name: &str,
9036    default_user_portable_root: &Path,
9037) -> PathBuf {
9038    if let Some(custom) = request_location
9039        && !custom.is_empty()
9040    {
9041        return PathBuf::from(custom);
9042    }
9043    if let Some(loc) = existing_location
9044        && !loc.is_empty()
9045    {
9046        return PathBuf::from(loc);
9047    }
9048    default_user_portable_root.join(subkey_name)
9049}
9050
9051#[cfg(windows)]
9052fn default_user_portable_root() -> PathBuf {
9053    dirs::data_local_dir()
9054        .unwrap_or_else(|| PathBuf::from("."))
9055        .join("Microsoft")
9056        .join("WinGet")
9057        .join("Packages")
9058}
9059
9060#[cfg(windows)]
9061fn clean_directory_contents(dir: &Path) -> Result<()> {
9062    if !dir.is_dir() {
9063        return Ok(());
9064    }
9065    for entry in fs::read_dir(dir)? {
9066        let entry = entry?;
9067        let path = entry.path();
9068        if path.is_dir() {
9069            fs::remove_dir_all(&path)?;
9070        } else {
9071            fs::remove_file(&path)?;
9072        }
9073    }
9074    Ok(())
9075}
9076
9077/// Writes the HKCU ARP entry winget would normally write for a portable
9078/// install. Only the subset that winget's portable list view and pinget's
9079/// `collect_uninstall_view` actually read.
9080#[cfg(windows)]
9081fn write_portable_arp_entry(entry: &PortableArpEntry<'_>) -> Result<()> {
9082    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
9083    let uninstall_path = format!(
9084        r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
9085        entry.subkey_name
9086    );
9087    let (subkey, _) = hkcu
9088        .create_subkey(&uninstall_path)
9089        .context("failed to create portable ARP registry subkey")?;
9090
9091    let manifest = entry.manifest;
9092    let install_location_str = entry.install_location.to_string_lossy().to_string();
9093    subkey.set_value("WinGetPackageIdentifier", &manifest.id)?;
9094    subkey.set_value("WinGetSourceIdentifier", &entry.source_identifier.to_owned())?;
9095    subkey.set_value("WinGetInstallerType", &"portable".to_owned())?;
9096    subkey.set_value("InstallLocation", &install_location_str)?;
9097    subkey.set_value(
9098        "PortableTargetFullPath",
9099        &entry.portable_target_full_path.to_string_lossy().to_string(),
9100    )?;
9101    if let Some(symlink_path) = entry.portable_symlink_full_path {
9102        subkey.set_value("PortableSymlinkFullPath", &symlink_path.to_string_lossy().to_string())?;
9103    }
9104    subkey.set_value(
9105        "InstallDirectoryAddedToPath",
9106        &(if entry.added_to_path { 1u32 } else { 0u32 }),
9107    )?;
9108    subkey.set_value(
9109        "InstallDirectoryCreated",
9110        &(if entry.install_directory_created { 1u32 } else { 0u32 }),
9111    )?;
9112    subkey.set_value(
9113        "DisplayName",
9114        &if manifest.name.is_empty() {
9115            manifest.id.clone()
9116        } else {
9117            manifest.name.clone()
9118        },
9119    )?;
9120    subkey.set_value("DisplayVersion", &manifest.version)?;
9121    if let Some(publisher) = manifest.publisher.as_ref() {
9122        subkey.set_value("Publisher", publisher)?;
9123    }
9124    subkey.set_value(
9125        "UninstallString",
9126        &format!("winget uninstall --product-code {}", entry.subkey_name),
9127    )?;
9128    let today = Utc::now().format("%Y%m%d").to_string();
9129    subkey.set_value("InstallDate", &today)?;
9130    if let Some(url) = manifest.package_url.as_ref() {
9131        subkey.set_value("URLInfoAbout", url)?;
9132    }
9133
9134    Ok(())
9135}
9136
9137#[cfg(test)]
9138mod tests {
9139    use std::io::{Read as _, Write};
9140    use std::net::{SocketAddr, TcpListener, TcpStream};
9141    use std::path::{Path, PathBuf};
9142    use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
9143    use std::thread::{self, JoinHandle};
9144
9145    use flate2::Compression;
9146    use flate2::write::DeflateEncoder;
9147    use zip::write::SimpleFileOptions;
9148
9149    use super::*;
9150
9151    fn sample_manifest(id: &str, version: &str, name: &str) -> Manifest {
9152        Manifest {
9153            id: id.to_owned(),
9154            name: name.to_owned(),
9155            version: version.to_owned(),
9156            channel: String::new(),
9157            publisher: Some("Acme".to_owned()),
9158            description: Some("desc".to_owned()),
9159            moniker: None,
9160            package_url: None,
9161            publisher_url: None,
9162            publisher_support_url: None,
9163            license: None,
9164            license_url: None,
9165            privacy_url: None,
9166            author: None,
9167            copyright: None,
9168            copyright_url: None,
9169            release_notes: None,
9170            release_notes_url: None,
9171            tags: Vec::new(),
9172            agreements: Vec::new(),
9173            package_dependencies: Vec::new(),
9174            documentation: Vec::new(),
9175            icons: Vec::new(),
9176            installers: Vec::new(),
9177            require_explicit_upgrade: false,
9178        }
9179    }
9180
9181    #[test]
9182    fn derive_download_filename_matches_winget_pattern() {
9183        let manifest = sample_manifest("Spotify.Spotify", "1.2.90.451.gb094aab0", "Spotify");
9184        let installer = Installer {
9185            architecture: Some("arm64".to_owned()),
9186            installer_type: Some("exe".to_owned()),
9187            scope: Some("user".to_owned()),
9188            locale: Some("en-US".to_owned()),
9189            ..Installer::default()
9190        };
9191        assert_eq!(
9192            derive_download_manifest_filename(&manifest, &installer),
9193            "Spotify_1.2.90.451.gb094aab0_User_Arm64_exe_en-US.yaml"
9194        );
9195    }
9196
9197    #[test]
9198    fn derive_download_filename_omits_optional_components() {
9199        let manifest = sample_manifest("7zip.7zip", "26.01", "7-Zip");
9200        let installer = Installer {
9201            architecture: Some("x64".to_owned()),
9202            installer_type: Some("exe".to_owned()),
9203            ..Installer::default()
9204        };
9205        // No scope, no locale → both omitted; architecture remains PascalCase.
9206        assert_eq!(
9207            derive_download_manifest_filename(&manifest, &installer),
9208            "7-Zip_26.01_X64_exe.yaml"
9209        );
9210    }
9211
9212    #[test]
9213    fn derive_download_filename_skips_unknown_version() {
9214        let manifest = sample_manifest("Test.Package", "Unknown", "Test");
9215        let installer = Installer {
9216            architecture: Some("neutral".to_owned()),
9217            installer_type: Some("msi".to_owned()),
9218            ..Installer::default()
9219        };
9220        assert_eq!(
9221            derive_download_manifest_filename(&manifest, &installer),
9222            "Test_Neutral_msi.yaml"
9223        );
9224    }
9225
9226    #[test]
9227    fn derive_download_filename_sanitizes_illegal_chars() {
9228        let manifest = sample_manifest("Vendor.App", "1.0", "Some/Bad:Name");
9229        let installer = Installer {
9230            architecture: Some("x64".to_owned()),
9231            installer_type: Some("exe".to_owned()),
9232            ..Installer::default()
9233        };
9234        // `/` and `:` are illegal in Windows filenames; both must be replaced.
9235        let filename = derive_download_manifest_filename(&manifest, &installer);
9236        assert!(!filename.contains('/') && !filename.contains(':'));
9237        assert!(filename.starts_with("Some_Bad_Name_1.0"));
9238    }
9239
9240    #[test]
9241    fn build_download_manifest_yaml_overrides_identity_fields() {
9242        let manifest = sample_manifest("App.Id", "2.0.0", "App Name");
9243        // Documents contain a stale PackageVersion (e.g. empty) — the typed
9244        // manifest's corrected version must win.
9245        let documents = serde_json::json!({
9246            "PackageIdentifier": "stale-id",
9247            "PackageVersion": "",
9248            "PackageName": "Old Name",
9249            "Channel": "preview",
9250            "Installers": [
9251                { "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/a" },
9252                { "Architecture": "x86", "InstallerType": "exe", "InstallerUrl": "https://example.com/b" },
9253            ],
9254        });
9255        let installer = Installer {
9256            architecture: Some("x64".to_owned()),
9257            installer_type: Some("exe".to_owned()),
9258            url: Some("https://example.com/a".to_owned()),
9259            ..Installer::default()
9260        };
9261        let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
9262        assert!(yaml.contains("PackageIdentifier: App.Id"));
9263        assert!(yaml.contains("PackageVersion: 2.0.0"));
9264        assert!(yaml.contains("PackageName: App Name"));
9265        assert!(yaml.contains("ManifestType: merged"));
9266        // Channel had a value in docs but the typed manifest's channel is empty
9267        // → expect it stripped, since winget download manifests don't carry it.
9268        assert!(!yaml.contains("Channel: preview"));
9269        // Installers array narrows to the single selected installer.
9270        assert_eq!(yaml.matches("InstallerUrl").count(), 1);
9271        assert!(yaml.contains("https://example.com/a"));
9272        assert!(!yaml.contains("https://example.com/b"));
9273    }
9274
9275    #[test]
9276    fn build_download_manifest_yaml_preserves_typed_channel() {
9277        let mut manifest = sample_manifest("App.Id", "2.0.0", "App Name");
9278        manifest.channel = "beta".to_owned();
9279        let documents = serde_json::json!({
9280            "PackageIdentifier": "App.Id",
9281            "PackageVersion": "2.0.0",
9282            "Installers": [],
9283        });
9284        let installer = Installer::default();
9285        let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
9286        assert!(yaml.contains("Channel: beta"));
9287    }
9288
9289    fn temp_app_root(label: &str) -> PathBuf {
9290        static TEMP_APP_ROOT_COUNTER: AtomicU64 = AtomicU64::new(0);
9291        std::env::temp_dir().join(format!(
9292            "pinget-rs-tests-{label}-{}-{}",
9293            std::process::id(),
9294            TEMP_APP_ROOT_COUNTER.fetch_add(1, AtomicOrdering::SeqCst)
9295        ))
9296    }
9297
9298    #[cfg(windows)]
9299    #[test]
9300    fn path_prefix_check_rejects_string_prefix_collisions() {
9301        // Regression: a naive str::starts_with would falsely consider
9302        // C:\Foobar a child of C:\Foo and let the Links\ sweep delete unrelated
9303        // symlinks. The component-aware check must reject these.
9304        assert!(!path_starts_with_case_insensitive(
9305            Path::new(r"C:\Foobar\bin.exe"),
9306            Path::new(r"C:\Foo"),
9307        ));
9308        assert!(!path_starts_with_case_insensitive(
9309            Path::new(r"C:\Foo\bin.exe.bak"),
9310            Path::new(r"C:\Foo\bin.exe"),
9311        ));
9312    }
9313
9314    #[cfg(windows)]
9315    #[test]
9316    fn path_prefix_check_accepts_real_children_and_self() {
9317        // Dir install: the symlink target lives inside the install dir.
9318        assert!(path_starts_with_case_insensitive(
9319            Path::new(r"C:\Foo\sub\bin.exe"),
9320            Path::new(r"C:\Foo"),
9321        ));
9322        // File install: the symlink points at the same file we removed.
9323        assert!(path_starts_with_case_insensitive(
9324            Path::new(r"C:\Foo\bin.exe"),
9325            Path::new(r"C:\Foo\bin.exe"),
9326        ));
9327    }
9328
9329    #[cfg(windows)]
9330    #[test]
9331    fn path_prefix_check_is_case_insensitive() {
9332        // Windows paths are case-insensitive — winget and pinget may write
9333        // them with different casing.
9334        assert!(path_starts_with_case_insensitive(
9335            Path::new(r"c:\users\test\appdata\local\microsoft\winget\packages\foo\bin.exe"),
9336            Path::new(r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\Foo"),
9337        ));
9338    }
9339
9340    #[cfg(windows)]
9341    #[test]
9342    fn portable_source_identifier_maps_winget_catalog_to_canonical_id() {
9343        let query = PackageQuery {
9344            source: Some("winget".to_owned()),
9345            ..PackageQuery::default()
9346        };
9347        assert_eq!(
9348            portable_source_identifier(&query),
9349            "Microsoft.Winget.Source_8wekyb3d8bbwe"
9350        );
9351        // Case-insensitive — manifests/catalog may report differently.
9352        let query = PackageQuery {
9353            source: Some("WINGET".to_owned()),
9354            ..PackageQuery::default()
9355        };
9356        assert_eq!(
9357            portable_source_identifier(&query),
9358            "Microsoft.Winget.Source_8wekyb3d8bbwe"
9359        );
9360    }
9361
9362    #[cfg(windows)]
9363    #[test]
9364    fn portable_source_identifier_passes_through_custom_sources() {
9365        let query = PackageQuery {
9366            source: Some("internal-feed".to_owned()),
9367            ..PackageQuery::default()
9368        };
9369        assert_eq!(portable_source_identifier(&query), "internal-feed");
9370    }
9371
9372    #[cfg(windows)]
9373    #[test]
9374    fn portable_source_identifier_defaults_for_missing_or_empty_source() {
9375        let query = PackageQuery {
9376            source: None,
9377            ..PackageQuery::default()
9378        };
9379        assert_eq!(
9380            portable_source_identifier(&query),
9381            "Microsoft.Winget.Source_8wekyb3d8bbwe"
9382        );
9383        let query = PackageQuery {
9384            source: Some(String::new()),
9385            ..PackageQuery::default()
9386        };
9387        assert_eq!(
9388            portable_source_identifier(&query),
9389            "Microsoft.Winget.Source_8wekyb3d8bbwe"
9390        );
9391    }
9392
9393    #[cfg(windows)]
9394    #[test]
9395    fn portable_subkey_name_matches_winget_format() {
9396        assert_eq!(
9397            portable_subkey_name("BurntSushi.ripgrep.MSVC", "Microsoft.Winget.Source_8wekyb3d8bbwe"),
9398            "BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe"
9399        );
9400    }
9401
9402    #[cfg(windows)]
9403    #[test]
9404    fn determine_portable_alias_prefers_nested_installer_alias() {
9405        let installer = Installer {
9406            nested_installer_files: vec![NestedInstallerFile {
9407                relative_file_path: "ripgrep/rg.exe".to_owned(),
9408                portable_command_alias: Some("rg".to_owned()),
9409            }],
9410            commands: vec!["other-name".to_owned()],
9411            ..Installer::default()
9412        };
9413        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("rg"));
9414    }
9415
9416    #[cfg(windows)]
9417    #[test]
9418    fn determine_portable_alias_falls_back_to_commands() {
9419        let installer = Installer {
9420            // No nested files at all (standalone portable case).
9421            commands: vec!["nuget".to_owned()],
9422            ..Installer::default()
9423        };
9424        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("nuget"));
9425
9426        // Nested files exist but none declare PortableCommandAlias.
9427        let installer = Installer {
9428            nested_installer_files: vec![NestedInstallerFile {
9429                relative_file_path: "subdir/bin.exe".to_owned(),
9430                portable_command_alias: None,
9431            }],
9432            commands: vec!["bin".to_owned()],
9433            ..Installer::default()
9434        };
9435        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("bin"));
9436    }
9437
9438    #[cfg(windows)]
9439    #[test]
9440    fn determine_portable_alias_returns_none_without_alias_or_commands() {
9441        let installer = Installer::default();
9442        assert!(determine_portable_alias(&installer).is_none());
9443    }
9444
9445    #[cfg(windows)]
9446    #[test]
9447    fn resolve_portable_install_location_prefers_request_override() {
9448        let result = resolve_portable_install_location_pure(
9449            Some(r"D:\Tools\rg"),
9450            Some(r"C:\Software\rg-test"),
9451            "Sub_Source",
9452            Path::new(r"C:\default"),
9453        );
9454        assert_eq!(result, PathBuf::from(r"D:\Tools\rg"));
9455    }
9456
9457    #[cfg(windows)]
9458    #[test]
9459    fn resolve_portable_install_location_preserves_existing_location_for_upgrades() {
9460        // The #4823 scenario: pinget upgrade with no explicit --location must
9461        // resolve to the install_location the prior install recorded, not the
9462        // default portable root.
9463        let result = resolve_portable_install_location_pure(
9464            None,
9465            Some(r"C:\Software\rg-test"),
9466            "Sub_Source",
9467            Path::new(r"C:\default"),
9468        );
9469        assert_eq!(result, PathBuf::from(r"C:\Software\rg-test"));
9470    }
9471
9472    #[cfg(windows)]
9473    #[test]
9474    fn resolve_portable_install_location_falls_back_to_default_root() {
9475        let result = resolve_portable_install_location_pure(None, None, "Sub_Source", Path::new(r"C:\default"));
9476        assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
9477    }
9478
9479    #[cfg(windows)]
9480    #[test]
9481    fn resolve_portable_install_location_treats_empty_strings_as_unset() {
9482        // Defensive: PackageQuery / registry reads can yield "" instead of
9483        // None for missing values.
9484        let result = resolve_portable_install_location_pure(Some(""), Some(""), "Sub_Source", Path::new(r"C:\default"));
9485        assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
9486    }
9487
9488    #[cfg(windows)]
9489    #[test]
9490    fn clean_directory_contents_removes_files_and_subdirs_but_keeps_root() {
9491        let root = std::env::temp_dir().join(format!(
9492            "pinget-clean-test-{}",
9493            Utc::now().timestamp_nanos_opt().unwrap_or_default()
9494        ));
9495        fs::create_dir_all(&root).expect("create temp dir");
9496        fs::write(root.join("a.txt"), b"hello").expect("write a.txt");
9497        fs::create_dir_all(root.join("nested")).expect("create nested dir");
9498        fs::write(root.join("nested").join("b.txt"), b"world").expect("write nested/b.txt");
9499
9500        clean_directory_contents(&root).expect("clean root contents");
9501
9502        assert!(root.is_dir(), "root dir itself must survive");
9503        assert_eq!(
9504            fs::read_dir(&root).expect("read root").count(),
9505            0,
9506            "root must be empty after clean"
9507        );
9508        let _ = fs::remove_dir_all(&root);
9509    }
9510
9511    #[cfg(windows)]
9512    #[test]
9513    fn clean_directory_contents_is_a_noop_on_nonexistent_dir() {
9514        let bogus = std::env::temp_dir().join(format!(
9515            "pinget-clean-test-bogus-{}",
9516            Utc::now().timestamp_nanos_opt().unwrap_or_default()
9517        ));
9518        assert!(!bogus.exists());
9519        clean_directory_contents(&bogus).expect("clean nonexistent dir");
9520        // Should not have created the dir.
9521        assert!(!bogus.exists());
9522    }
9523
9524    #[cfg(windows)]
9525    #[test]
9526    fn path_prefix_check_handles_mixed_separators() {
9527        // NestedInstallerFiles RelativeFilePath uses '/' in manifests, so the
9528        // path we joined can end up mixed-separator on Windows.
9529        assert!(path_starts_with_case_insensitive(
9530            Path::new(r"C:\Foo\sub/bin.exe"),
9531            Path::new(r"C:\Foo"),
9532        ));
9533    }
9534
9535    #[test]
9536    fn repository_options_capture_custom_host_settings() {
9537        let options = RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test"))
9538            .with_user_agent("pinget-rs-tests/1.0")
9539            .with_pre_indexed_source_auto_update_interval(None);
9540
9541        assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
9542        assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
9543        assert!(options.pre_indexed_source_auto_update_interval.is_none());
9544    }
9545
9546    #[test]
9547    fn dependency_install_requests_are_silent_and_not_forced() {
9548        let mut parent = InstallRequest::new(PackageQuery {
9549            source: Some("winget".to_owned()),
9550            ..PackageQuery::default()
9551        });
9552        parent.mode = InstallerMode::SilentWithProgress;
9553        parent.force = true;
9554        parent.accept_package_agreements = true;
9555        parent.ignore_security_hash = true;
9556        parent.dependency_source = Some("dependencies".to_owned());
9557
9558        let dependency =
9559            Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
9560
9561        assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
9562        assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
9563        assert_eq!(dependency.mode, InstallerMode::Silent);
9564        assert!(!dependency.force);
9565        assert!(dependency.no_upgrade);
9566        assert!(dependency.accept_package_agreements);
9567        assert!(dependency.ignore_security_hash);
9568    }
9569
9570    #[test]
9571    fn storage_paths_use_configured_app_root() {
9572        let app_root = PathBuf::from(r"C:\temp\pinget-test");
9573        let source = SourceRecord {
9574            name: "winget/test".to_owned(),
9575            kind: SourceKind::PreIndexed,
9576            arg: "https://example.com/cache".to_owned(),
9577            identifier: "Test.Source".to_owned(),
9578            trust_level: "None".to_owned(),
9579            explicit: false,
9580            priority: 0,
9581            last_update: None,
9582            source_version: None,
9583        };
9584
9585        assert_eq!(store_path(&app_root), app_root.join("sources.json"));
9586        assert_eq!(
9587            source_state_dir(&app_root, &source),
9588            app_root.join("sources").join("winget_test")
9589        );
9590        assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
9591    }
9592
9593    #[cfg(windows)]
9594    #[test]
9595    fn packaged_layout_paths_resolve_from_packaged_app_root() {
9596        let local_app_data = dirs::data_local_dir().expect("LocalAppData");
9597        let app_root = local_app_data
9598            .join("Packages")
9599            .join(PACKAGED_FAMILY_NAME)
9600            .join("LocalState");
9601        assert!(uses_packaged_layout(&app_root));
9602        assert!(
9603            user_settings_path(&app_root)
9604                .display()
9605                .to_string()
9606                .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
9607        );
9608        assert!(
9609            packaged_file_cache_root(&app_root)
9610                .display()
9611                .to_string()
9612                .ends_with(&format!(
9613                    r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
9614                    PACKAGED_FAMILY_NAME
9615                ))
9616        );
9617    }
9618
9619    #[cfg(windows)]
9620    #[test]
9621    fn default_app_root_outside_package_avoids_packaged_layout() {
9622        // Tests run without AppX package identity, so the default app root must not
9623        // resolve to the WinGet packaged LocalState — that location requires a
9624        // brokered/elevated writer for its secure-settings stream.
9625        let prior = std::env::var_os("PINGET_APPROOT");
9626        // SAFETY: tests in this module are not run concurrently with other env mutators.
9627        unsafe { std::env::remove_var("PINGET_APPROOT") };
9628
9629        let result = default_app_root();
9630
9631        if let Some(prior) = prior {
9632            // SAFETY: restoring the prior value before assertions panic, single-threaded test.
9633            unsafe { std::env::set_var("PINGET_APPROOT", prior) };
9634        }
9635
9636        let app_root = result.expect("default app root");
9637        assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
9638        assert!(
9639            app_root.ends_with(Path::new("Devolutions").join("Pinget")),
9640            "unexpected fallback app root: {app_root:?}"
9641        );
9642    }
9643
9644    #[test]
9645    fn packaged_source_stream_overlays_defaults_and_metadata() {
9646        let store = parse_packaged_source_store(
9647            Some(
9648                "Sources:\n  - Name: winget\n    Type: Microsoft.PreIndexed.Package\n    Arg: https://cdn.winget.microsoft.com/cache\n    Data: Microsoft.Winget.Source_8wekyb3d8bbwe\n    IsTombstone: true\n  - Name: corp\n    Type: Microsoft.Rest\n    Arg: https://packages.contoso.test/api\n    Data: Contoso.Rest\n    Explicit: true\n    Priority: 7\n    TrustLevel: 1\n    IsTombstone: false\n",
9649            ),
9650            Some(
9651                "Sources:\n  - Name: corp\n    LastUpdate: 1700000000\n    SourceVersion: 1.2.3\n",
9652            ),
9653        )
9654        .expect("packaged store");
9655
9656        assert!(!store.sources.iter().any(|source| source.name == "winget"));
9657        let corp = store
9658            .sources
9659            .iter()
9660            .find(|source| source.name == "corp")
9661            .expect("corp source");
9662        assert_eq!(corp.kind, SourceKind::Rest);
9663        assert_eq!(corp.identifier, "Contoso.Rest");
9664        assert_eq!(corp.trust_level, "Trusted");
9665        assert!(corp.explicit);
9666        assert_eq!(corp.priority, 7);
9667        assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
9668        assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
9669    }
9670
9671    #[test]
9672    fn system_winget_export_parses_json_lines() {
9673        let output = r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
9674{"Arg":"https://cdn.contoso.test/cache","Data":"Contoso.Source_8wekyb3d8bbwe","Explicit":true,"Identifier":"Contoso.Source_8wekyb3d8bbwe","Name":"contoso-cache","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"}
9675"#;
9676
9677        let sources = parse_system_winget_source_export(output).expect("source export");
9678
9679        assert_eq!(
9680            sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
9681            vec!["contoso", "contoso-cache"]
9682        );
9683        assert_eq!(sources[0].kind, SourceKind::Rest);
9684        assert_eq!(sources[0].arg, "https://api.contoso.test/feed");
9685        assert_eq!(sources[0].identifier, "api.contoso.test");
9686        assert_eq!(sources[0].trust_level, "Trusted");
9687        assert!(!sources[0].explicit);
9688        assert_eq!(sources[1].kind, SourceKind::PreIndexed);
9689        assert_eq!(sources[1].identifier, "Contoso.Source_8wekyb3d8bbwe");
9690        assert!(sources[1].explicit);
9691    }
9692
9693    #[test]
9694    fn system_winget_export_parses_wrapped_sources() {
9695        let output = r#"{
9696  "Sources": [
9697    {
9698      "Name": "corp",
9699      "Type": "Microsoft.Rest",
9700      "Arg": "https://packages.contoso.test/api",
9701      "Identifier": "Contoso.Rest",
9702      "TrustLevel": "Trusted",
9703      "Explicit": true,
9704      "Priority": 9
9705    }
9706  ]
9707}"#;
9708
9709        let sources = parse_system_winget_source_export(output).expect("source export");
9710        let source = sources.first().expect("source");
9711        assert_eq!(sources.len(), 1);
9712        assert_eq!(source.name, "corp");
9713        assert_eq!(source.kind, SourceKind::Rest);
9714        assert_eq!(source.identifier, "Contoso.Rest");
9715        assert_eq!(source.trust_level, "Trusted");
9716        assert!(source.explicit);
9717        assert_eq!(source.priority, 9);
9718    }
9719
9720    #[test]
9721    fn system_winget_source_store_detects_secure_settings_stub() {
9722        assert!(is_secure_settings_stub(Some(
9723            "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
9724        )));
9725        assert!(!is_secure_settings_stub(Some("Sources:\n  - Name: winget\n")));
9726    }
9727
9728    #[test]
9729    fn system_winget_source_store_builds_add_arguments() {
9730        let args = build_winget_source_add_arguments(
9731            "corp",
9732            "https://packages.contoso.test/api",
9733            SourceKind::Rest,
9734            "Trusted",
9735            true,
9736        );
9737
9738        assert_eq!(
9739            args,
9740            vec![
9741                "source",
9742                "add",
9743                "--name",
9744                "corp",
9745                "--arg",
9746                "https://packages.contoso.test/api",
9747                "--type",
9748                "Microsoft.Rest",
9749                "--disable-interactivity",
9750                "--trust-level",
9751                "trusted",
9752                "--explicit",
9753            ]
9754        );
9755    }
9756
9757    fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
9758        Ok(WingetSourceCommandResult {
9759            exit_code: 0,
9760            stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
9761"#
9762            .to_owned(),
9763            stderr: String::new(),
9764        })
9765    }
9766
9767    #[test]
9768    fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
9769        let original_runner = {
9770            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
9771            let original = *runner;
9772            *runner = fake_system_winget_source_export;
9773            original
9774        };
9775
9776        let result = load_packaged_store_from_streams(
9777            Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
9778            None,
9779        );
9780
9781        {
9782            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
9783            *runner = original_runner;
9784        }
9785
9786        let store = result.expect("packaged store").expect("packaged store");
9787        let source = store.sources.first().expect("source");
9788        assert_eq!(store.sources.len(), 1);
9789        assert_eq!(source.name, "contoso");
9790        assert_eq!(source.kind, SourceKind::Rest);
9791    }
9792
9793    #[test]
9794    fn source_metadata_and_named_reset_round_trip() {
9795        let app_root = temp_app_root("source-reset");
9796        let result = (|| -> Result<()> {
9797            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9798            repository.add_source_with_metadata(
9799                "test",
9800                "https://example.com/test",
9801                SourceKind::Rest,
9802                Some("trusted"),
9803                true,
9804                4,
9805            )?;
9806
9807            repository.edit_source("test", Some(false), None)?;
9808            let edited = repository
9809                .list_sources()
9810                .into_iter()
9811                .find(|source| source.name == "test")
9812                .expect("source");
9813            assert_eq!(edited.trust_level, "Trusted");
9814            assert!(!edited.explicit);
9815            assert_eq!(edited.priority, 4);
9816
9817            repository.reset_source("test")?;
9818            let reset = repository
9819                .list_sources()
9820                .into_iter()
9821                .find(|source| source.name == "test")
9822                .expect("source");
9823            assert_eq!(reset.trust_level, "Trusted");
9824            assert!(!reset.explicit);
9825            assert_eq!(reset.priority, 4);
9826            assert!(reset.last_update.is_none());
9827            assert!(reset.source_version.is_none());
9828            Ok(())
9829        })();
9830
9831        let _ = fs::remove_dir_all(&app_root);
9832        result.expect("source metadata");
9833    }
9834
9835    #[test]
9836    fn explicit_sources_are_skipped_by_default_search_resolution() {
9837        let app_root = temp_app_root("source-resolution");
9838        let result = (|| -> Result<()> {
9839            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9840            repository.add_source_with_metadata(
9841                "explicit-test",
9842                "https://example.com/explicit",
9843                SourceKind::Rest,
9844                Some("trusted"),
9845                true,
9846                10,
9847            )?;
9848
9849            let indexes = repository.resolve_source_indexes(None)?;
9850            let names: Vec<_> = indexes
9851                .into_iter()
9852                .map(|index| repository.list_sources()[index].name.clone())
9853                .collect();
9854            assert!(!names.iter().any(|name| name == "explicit-test"));
9855            Ok(())
9856        })();
9857
9858        let _ = fs::remove_dir_all(&app_root);
9859        result.expect("source resolution");
9860    }
9861
9862    #[test]
9863    fn user_and_admin_settings_round_trip() {
9864        let app_root = temp_app_root("settings");
9865        let result = (|| -> Result<()> {
9866            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9867            repository.set_user_settings(
9868                &serde_json::json!({
9869                    "visual": {
9870                        "progressBar": "retro"
9871                    }
9872                }),
9873                false,
9874            )?;
9875            repository.set_user_settings(
9876                &serde_json::json!({
9877                    "experimentalFeatures": {
9878                        "directMSI": true
9879                    }
9880                }),
9881                true,
9882            )?;
9883
9884            let user_settings = repository.get_user_settings()?;
9885            assert_eq!(
9886                user_settings["visual"]["progressBar"],
9887                serde_json::Value::String("retro".to_owned())
9888            );
9889            assert_eq!(
9890                user_settings["experimentalFeatures"]["directMSI"],
9891                serde_json::Value::Bool(true)
9892            );
9893            assert!(repository.test_user_settings(
9894                &serde_json::json!({
9895                    "experimentalFeatures": {
9896                        "directMSI": true
9897                    }
9898                }),
9899                true
9900            )?);
9901
9902            repository.set_admin_setting("LocalManifestFiles", true)?;
9903            repository.set_admin_setting("InstallerHashOverride", true)?;
9904            let admin_settings = repository.get_admin_settings()?;
9905            assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
9906            assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
9907
9908            repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
9909            let reset_one = repository.get_admin_settings()?;
9910            assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
9911
9912            repository.reset_admin_setting(None, true)?;
9913            let reset_all = repository.get_admin_settings()?;
9914            for name in Repository::supported_admin_settings() {
9915                assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
9916            }
9917            Ok(())
9918        })();
9919
9920        let _ = fs::remove_dir_all(&app_root);
9921        result.expect("settings");
9922    }
9923
9924    #[test]
9925    fn show_result_structured_document_is_manifest_oriented() {
9926        let result = ShowResult {
9927            package: SearchMatch {
9928                source_name: "winget".to_owned(),
9929                source_kind: SourceKind::PreIndexed,
9930                id: "Test.Package".to_owned(),
9931                name: "Test Package".to_owned(),
9932                moniker: Some("testpkg".to_owned()),
9933                version: Some("1.2.3".to_owned()),
9934                channel: Some("stable".to_owned()),
9935                match_criteria: Some("Id".to_owned()),
9936            },
9937            manifest: Manifest {
9938                id: "Test.Package".to_owned(),
9939                name: "Test Package".to_owned(),
9940                version: "1.2.3".to_owned(),
9941                channel: "stable".to_owned(),
9942                publisher: Some("Contoso".to_owned()),
9943                description: Some("Structured output".to_owned()),
9944                moniker: Some("testpkg".to_owned()),
9945                package_url: None,
9946                publisher_url: None,
9947                publisher_support_url: None,
9948                license: None,
9949                license_url: None,
9950                privacy_url: None,
9951                author: None,
9952                copyright: None,
9953                copyright_url: None,
9954                release_notes: None,
9955                release_notes_url: None,
9956                tags: vec!["utility".to_owned()],
9957                agreements: Vec::new(),
9958                package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
9959                documentation: vec![Documentation {
9960                    label: Some("Docs".to_owned()),
9961                    url: "https://example.test/docs".to_owned(),
9962                }],
9963                icons: vec![PackageIcon {
9964                    icon_url: Some("https://example.test/default-256.ico".to_owned()),
9965                    icon_file_type: Some("ico".to_owned()),
9966                    icon_resolution: Some("256x256".to_owned()),
9967                    icon_theme: Some("default".to_owned()),
9968                    icon_sha256: Some("DEF456".to_owned()),
9969                }],
9970                installers: vec![Installer {
9971                    architecture: Some("x64".to_owned()),
9972                    installer_type: Some("msix".to_owned()),
9973                    url: Some("https://example.test/Test.Package.msix".to_owned()),
9974                    sha256: Some("ABC123".to_owned()),
9975                    product_code: None,
9976                    locale: Some("en-US".to_owned()),
9977                    scope: Some("machine".to_owned()),
9978                    release_date: None,
9979                    package_family_name: None,
9980                    upgrade_code: None,
9981                    platforms: Vec::new(),
9982                    minimum_os_version: None,
9983                    switches: InstallerSwitches {
9984                        silent: Some("/quiet".to_owned()),
9985                        ..InstallerSwitches::default()
9986                    },
9987                    commands: vec!["testpkg".to_owned()],
9988                    package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
9989                    require_explicit_upgrade: false,
9990                    nested_installer_type: None,
9991                    nested_installer_files: Vec::new(),
9992                }],
9993                require_explicit_upgrade: false,
9994            },
9995            selected_installer: Some(Installer {
9996                architecture: Some("x64".to_owned()),
9997                installer_type: Some("msix".to_owned()),
9998                url: Some("https://example.test/Test.Package.msix".to_owned()),
9999                sha256: Some("ABC123".to_owned()),
10000                product_code: None,
10001                locale: Some("en-US".to_owned()),
10002                scope: Some("machine".to_owned()),
10003                release_date: None,
10004                package_family_name: None,
10005                upgrade_code: None,
10006                platforms: Vec::new(),
10007                minimum_os_version: None,
10008                switches: InstallerSwitches {
10009                    silent: Some("/quiet".to_owned()),
10010                    ..InstallerSwitches::default()
10011                },
10012                commands: vec!["testpkg".to_owned()],
10013                package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
10014                require_explicit_upgrade: false,
10015                nested_installer_type: None,
10016                nested_installer_files: Vec::new(),
10017            }),
10018            cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
10019            warnings: vec!["cache warmed".to_owned()],
10020            manifest_documents: JsonValue::Array(vec![
10021                serde_json::json!({
10022                    "PackageIdentifier": "Test.Package",
10023                    "PackageVersion": "1.2.3",
10024                    "DefaultLocale": "en-US",
10025                    "ManifestType": "version",
10026                    "ManifestVersion": "1.10.0"
10027                }),
10028                serde_json::json!({
10029                    "PackageIdentifier": "Test.Package",
10030                    "PackageVersion": "1.2.3",
10031                    "PackageLocale": "en-US",
10032                    "PackageName": "Test Package",
10033                    "Publisher": "Example",
10034                    "License": "MIT",
10035                    "ShortDescription": "Structured output",
10036                    "Icons": [
10037                        {
10038                            "IconUrl": "https://example.test/default-256.ico",
10039                            "IconFileType": "ico",
10040                            "IconResolution": "256x256",
10041                            "IconTheme": "default",
10042                            "IconSha256": "DEF456"
10043                        }
10044                    ],
10045                    "ManifestType": "defaultLocale",
10046                    "ManifestVersion": "1.10.0"
10047                }),
10048                serde_json::json!({
10049                    "PackageIdentifier": "Test.Package",
10050                    "PackageVersion": "1.2.3",
10051                    "ManifestType": "installer",
10052                    "ManifestVersion": "1.10.0",
10053                    "Installers": [
10054                        {
10055                            "Architecture": "x64",
10056                            "InstallerType": "msix",
10057                            "InstallerUrl": "https://example.test/Test.Package.msix",
10058                            "InstallerSha256": "ABC123",
10059                            "Commands": ["testpkg"],
10060                            "InstallerSwitches": { "Silent": "/quiet" },
10061                            "Dependencies": {
10062                                "PackageDependencies": [
10063                                    { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
10064                                ]
10065                            }
10066                        }
10067                    ]
10068                }),
10069            ]),
10070        };
10071
10072        let document = result.structured_document();
10073
10074        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
10075        assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
10076        assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
10077        assert_eq!(
10078            document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
10079            Some("Microsoft.VCRedist.2015+.x64")
10080        );
10081        assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
10082        assert_eq!(
10083            document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
10084            Some("/quiet")
10085        );
10086        assert_eq!(
10087            document["IconUrl"].as_str(),
10088            Some("https://example.test/default-256.ico")
10089        );
10090        assert_eq!(document["Icon"]["IconFileType"].as_str(), Some("ico"));
10091        assert_eq!(
10092            document["Icons"][0]["IconUrl"].as_str(),
10093            Some("https://example.test/default-256.ico")
10094        );
10095    }
10096
10097    #[test]
10098    fn manifest_parses_require_explicit_upgrade_at_top_level() {
10099        // Top-level RequireExplicitUpgrade flag should propagate to
10100        // Manifest.require_explicit_upgrade. winget catalogs typically
10101        // place the flag here for browser packages and similar
10102        // self-updating apps that opt out of bulk `upgrade`.
10103        let yaml = r#"
10104PackageIdentifier: Test.Package
10105PackageVersion: 1.2.3
10106DefaultLocale: en-US
10107ManifestType: singleton
10108ManifestVersion: 1.10.0
10109PackageLocale: en-US
10110PackageName: Test Package
10111Publisher: Example
10112License: MIT
10113ShortDescription: explicit-upgrade fixture
10114RequireExplicitUpgrade: true
10115Installers:
10116  - Architecture: x64
10117    InstallerType: exe
10118    InstallerUrl: https://example.test/Test.Package.exe
10119    InstallerSha256: ABC123
10120"#;
10121        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
10122        assert!(manifest.require_explicit_upgrade);
10123    }
10124
10125    #[test]
10126    fn manifest_parses_require_explicit_upgrade_on_installer() {
10127        // Per-installer flag — only one of several installers declares it,
10128        // but the Manifest aggregate must still be `true` because the user
10129        // could pick that installer when upgrading.
10130        let yaml = r#"
10131PackageIdentifier: Test.Package
10132PackageVersion: 1.2.3
10133DefaultLocale: en-US
10134ManifestType: singleton
10135ManifestVersion: 1.10.0
10136PackageLocale: en-US
10137PackageName: Test Package
10138Publisher: Example
10139License: MIT
10140ShortDescription: explicit-upgrade fixture
10141Installers:
10142  - Architecture: x64
10143    InstallerType: exe
10144    InstallerUrl: https://example.test/Test.Package.x64.exe
10145    InstallerSha256: ABC123
10146  - Architecture: arm64
10147    InstallerType: exe
10148    InstallerUrl: https://example.test/Test.Package.arm64.exe
10149    InstallerSha256: DEF456
10150    RequireExplicitUpgrade: true
10151"#;
10152        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
10153        assert!(manifest.require_explicit_upgrade);
10154        assert!(!manifest.installers[0].require_explicit_upgrade);
10155        assert!(manifest.installers[1].require_explicit_upgrade);
10156    }
10157
10158    #[test]
10159    fn manifest_without_require_explicit_upgrade_defaults_to_false() {
10160        let yaml = r#"
10161PackageIdentifier: Test.Package
10162PackageVersion: 1.2.3
10163DefaultLocale: en-US
10164ManifestType: singleton
10165ManifestVersion: 1.10.0
10166PackageLocale: en-US
10167PackageName: Test Package
10168Publisher: Example
10169License: MIT
10170ShortDescription: baseline fixture
10171Installers:
10172  - Architecture: x64
10173    InstallerType: exe
10174    InstallerUrl: https://example.test/Test.Package.exe
10175    InstallerSha256: ABC123
10176"#;
10177        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
10178        assert!(!manifest.require_explicit_upgrade);
10179    }
10180
10181    #[test]
10182    fn manifest_parses_icons_and_selects_best_icon_from_yaml() {
10183        let yaml = r#"
10184PackageIdentifier: Test.Package
10185PackageVersion: 1.2.3
10186DefaultLocale: en-US
10187ManifestType: singleton
10188ManifestVersion: 1.10.0
10189PackageLocale: en-US
10190PackageName: Test Package
10191Publisher: Example
10192License: MIT
10193ShortDescription: icon fixture
10194Icons:
10195  - IconUrl: https://example.test/dark-512.png
10196    IconFileType: png
10197    IconResolution: 512x512
10198    IconTheme: dark
10199    IconSha256: DARK
10200  - IconUrl: https://example.test/default-64.png
10201    IconFileType: png
10202    IconResolution: 64x64
10203    IconTheme: default
10204  - IconUrl: https://example.test/default-256.ico
10205    IconFileType: ico
10206    IconResolution: 256x256
10207    IconTheme: default
10208  - IconFileType: png
10209    IconResolution: 1024x1024
10210Installers:
10211  - Architecture: x64
10212    InstallerType: exe
10213    InstallerUrl: https://example.test/Test.Package.exe
10214    InstallerSha256: ABC123
10215"#;
10216        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
10217
10218        assert_eq!(manifest.icons.len(), 4);
10219        assert_eq!(
10220            manifest.icons[0].icon_url.as_deref(),
10221            Some("https://example.test/dark-512.png")
10222        );
10223        assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
10224        let selected = manifest.icon().expect("selected icon");
10225        assert_eq!(selected.icon_file_type.as_deref(), Some("ico"));
10226        assert_eq!(selected.icon_resolution.as_deref(), Some("256x256"));
10227    }
10228
10229    #[test]
10230    fn manifest_without_icons_returns_empty_icon_list_and_no_selection() {
10231        let yaml = r#"
10232PackageIdentifier: Test.Package
10233PackageVersion: 1.2.3
10234DefaultLocale: en-US
10235ManifestType: singleton
10236ManifestVersion: 1.10.0
10237PackageLocale: en-US
10238PackageName: Test Package
10239Publisher: Example
10240License: MIT
10241ShortDescription: no-icon fixture
10242Installers:
10243  - Architecture: x64
10244    InstallerType: exe
10245    InstallerUrl: https://example.test/Test.Package.exe
10246    InstallerSha256: ABC123
10247"#;
10248        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
10249
10250        assert!(manifest.icons.is_empty());
10251        assert!(manifest.icon().is_none());
10252        assert!(manifest.icon_url().is_none());
10253    }
10254
10255    #[test]
10256    fn rest_manifest_parses_icons_from_default_locale() {
10257        let response = serde_json::json!({
10258            "Data": {
10259                "PackageIdentifier": "Test.Package",
10260                "Versions": [
10261                    {
10262                        "PackageVersion": "1.2.3",
10263                        "DefaultLocale": {
10264                            "PackageName": "Test Package",
10265                            "Publisher": "Example",
10266                            "ShortDescription": "REST icon fixture",
10267                            "Icons": [
10268                                {
10269                                    "IconUrl": "https://example.test/default-128.png",
10270                                    "IconFileType": "png",
10271                                    "IconResolution": "128x128",
10272                                    "IconTheme": "default"
10273                                },
10274                                {
10275                                    "IconUrl": "https://example.test/default-256.ico",
10276                                    "IconFileType": "ico",
10277                                    "IconResolution": "256x256"
10278                                }
10279                            ]
10280                        },
10281                        "Installers": [
10282                            {
10283                                "Architecture": "x64",
10284                                "InstallerType": "exe",
10285                                "InstallerUrl": "https://example.test/Test.Package.exe",
10286                                "InstallerSha256": "ABC123"
10287                            }
10288                        ],
10289                        "ManifestVersion": "1.10.0"
10290                    }
10291                ],
10292                "ManifestVersion": "1.10.0"
10293            }
10294        });
10295        let bytes = serde_json::to_vec(&response).expect("json");
10296
10297        let (manifest, document) = parse_rest_manifest(&bytes, "Test.Package", "1.2.3", "").expect("parse");
10298
10299        assert_eq!(manifest.icons.len(), 2);
10300        assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
10301        assert_eq!(
10302            document["Icons"][0]["IconUrl"].as_str(),
10303            Some("https://example.test/default-128.png")
10304        );
10305    }
10306
10307    #[test]
10308    fn upgrade_filter_hides_require_explicit_upgrade_by_default() {
10309        // winget hides `RequireExplicitUpgrade` rows from bulk `upgrade`
10310        // (Edge, Steam, Discord). pinget must do the same — the filter is
10311        // the only gate that enforces it.
10312        let mut pkg = InstalledPackage {
10313            name: "Edge".to_owned(),
10314            local_id: r"ARP\Machine\X64\Edge".to_owned(),
10315            installed_version: "100.0".to_owned(),
10316            publisher: None,
10317            scope: Some("Machine".to_owned()),
10318            installer_category: Some("exe".to_owned()),
10319            install_location: None,
10320            package_family_names: Vec::new(),
10321            product_codes: Vec::new(),
10322            upgrade_codes: Vec::new(),
10323            correlated: Some(SearchMatch {
10324                source_name: "winget".to_owned(),
10325                source_kind: SourceKind::PreIndexed,
10326                id: "Microsoft.Edge".to_owned(),
10327                name: "Microsoft Edge".to_owned(),
10328                moniker: None,
10329                version: Some("110.0".to_owned()),
10330                channel: None,
10331                match_criteria: None,
10332            }),
10333            installed_version_canonical: false,
10334            correlated_requires_explicit_upgrade: true,
10335            correlated_lacks_compatible_installer: false,
10336        };
10337        let bulk_query = ListQuery {
10338            upgrade_only: true,
10339            ..ListQuery::default()
10340        };
10341        assert!(
10342            !installed_package_matches_upgrade_filter(&pkg, &bulk_query),
10343            "RequireExplicitUpgrade row must be hidden from bulk upgrade"
10344        );
10345
10346        // When the user explicitly targets it by id, winget shows it —
10347        // pinget must do the same so `pinget upgrade Microsoft.Edge` works.
10348        let filtered_query = ListQuery {
10349            upgrade_only: true,
10350            id: Some("Microsoft.Edge".to_owned()),
10351            ..ListQuery::default()
10352        };
10353        assert!(
10354            installed_package_matches_upgrade_filter(&pkg, &filtered_query),
10355            "RequireExplicitUpgrade row must surface when the user filters for it explicitly"
10356        );
10357
10358        // Without the flag, the row appears in bulk upgrade as usual.
10359        pkg.correlated_requires_explicit_upgrade = false;
10360        assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
10361    }
10362
10363    #[test]
10364    fn upgrade_filter_hides_lacks_compatible_installer_by_default() {
10365        let mut pkg = InstalledPackage {
10366            name: "Foo".to_owned(),
10367            local_id: r"ARP\Machine\X64\Foo".to_owned(),
10368            installed_version: "1.0".to_owned(),
10369            publisher: None,
10370            scope: Some("Machine".to_owned()),
10371            installer_category: Some("exe".to_owned()),
10372            install_location: None,
10373            package_family_names: Vec::new(),
10374            product_codes: Vec::new(),
10375            upgrade_codes: Vec::new(),
10376            correlated: Some(SearchMatch {
10377                source_name: "winget".to_owned(),
10378                source_kind: SourceKind::PreIndexed,
10379                id: "Test.Foo".to_owned(),
10380                name: "Foo".to_owned(),
10381                moniker: None,
10382                version: Some("2.0".to_owned()),
10383                channel: None,
10384                match_criteria: None,
10385            }),
10386            installed_version_canonical: false,
10387            correlated_requires_explicit_upgrade: false,
10388            correlated_lacks_compatible_installer: true,
10389        };
10390        let bulk_query = ListQuery {
10391            upgrade_only: true,
10392            ..ListQuery::default()
10393        };
10394        assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query));
10395
10396        // Explicit `--id` still surfaces it.
10397        let filtered_query = ListQuery {
10398            upgrade_only: true,
10399            id: Some("Test.Foo".to_owned()),
10400            ..ListQuery::default()
10401        };
10402        assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query));
10403
10404        // Cleared flag → visible in bulk.
10405        pkg.correlated_lacks_compatible_installer = false;
10406        assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
10407    }
10408
10409    fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest {
10410        let installers = arches
10411            .iter()
10412            .map(|a| Installer {
10413                architecture: Some((*a).to_owned()),
10414                installer_type: None,
10415                url: None,
10416                sha256: None,
10417                product_code: None,
10418                locale: None,
10419                scope: None,
10420                release_date: None,
10421                package_family_name: None,
10422                upgrade_code: None,
10423                platforms: Vec::new(),
10424                minimum_os_version: None,
10425                switches: InstallerSwitches::default(),
10426                commands: Vec::new(),
10427                package_dependencies: Vec::new(),
10428                require_explicit_upgrade: false,
10429                nested_installer_type: None,
10430                nested_installer_files: Vec::new(),
10431            })
10432            .collect();
10433        Manifest {
10434            id: "Test".to_owned(),
10435            name: "Test".to_owned(),
10436            version: "1.0".to_owned(),
10437            channel: String::new(),
10438            publisher: None,
10439            description: None,
10440            moniker: None,
10441            package_url: None,
10442            publisher_url: None,
10443            publisher_support_url: None,
10444            license: None,
10445            license_url: None,
10446            privacy_url: None,
10447            author: None,
10448            copyright: None,
10449            copyright_url: None,
10450            release_notes: None,
10451            release_notes_url: None,
10452            tags: Vec::new(),
10453            agreements: Vec::new(),
10454            package_dependencies: Vec::new(),
10455            documentation: Vec::new(),
10456            icons: Vec::new(),
10457            installers,
10458            require_explicit_upgrade: false,
10459        }
10460    }
10461
10462    #[test]
10463    fn manifest_compatible_installer_neutral_always_matches() {
10464        // `neutral` is in every host's allowed list.
10465        assert!(manifest_has_compatible_installer(
10466            &synthetic_manifest_with_installer_arches(&["neutral"])
10467        ));
10468    }
10469
10470    #[test]
10471    fn manifest_compatible_installer_empty_installers_pass_through() {
10472        // Manifest with no installers (e.g. a docs-only entry): nothing to
10473        // filter against, don't hide the row.
10474        assert!(manifest_has_compatible_installer(
10475            &synthetic_manifest_with_installer_arches(&[])
10476        ));
10477    }
10478
10479    #[test]
10480    fn manifest_compatible_installer_rejects_alien_arch_only() {
10481        // `ppc` is in no host's allowed list — manifest has nothing runnable.
10482        assert!(!manifest_has_compatible_installer(
10483            &synthetic_manifest_with_installer_arches(&["ppc"])
10484        ));
10485    }
10486
10487    #[test]
10488    fn manifest_compatible_installer_mixed_set_passes_if_any_match() {
10489        // Mixed list where at least `neutral` is in every host's preference.
10490        assert!(manifest_has_compatible_installer(
10491            &synthetic_manifest_with_installer_arches(&["ppc", "neutral"])
10492        ));
10493    }
10494
10495    #[test]
10496    fn lookup_unique_normalized_identity_returns_unique_match() {
10497        // Single (norm_name, norm_publisher) intersection — happy path.
10498        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10499        execute_batch_sql(
10500            &connection,
10501            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10502             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10503             INSERT INTO norm_names2 VALUES ('microsoftedge', 100);\n\
10504             INSERT INTO norm_publishers2 VALUES ('microsoft', 100);",
10505        )
10506        .expect("seed schema");
10507
10508        let rowid = lookup_unique_normalized_identity(&connection, "microsoftedge", "microsoft")
10509            .expect("query")
10510            .expect("unique match");
10511        assert_eq!(rowid, 100);
10512    }
10513
10514    #[test]
10515    fn lookup_unique_normalized_identity_rejects_ambiguous_match() {
10516        // Two distinct packages share the same (norm_name, norm_publisher)
10517        // — the Git case where both `Git.Git` and a hypothetical sibling
10518        // normalize identically. winget refuses to correlate when it can't
10519        // disambiguate; pinget must do the same.
10520        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10521        execute_batch_sql(
10522            &connection,
10523            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10524             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10525             INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
10526             INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('thegitdevelopmentcommunity', 200);",
10527        )
10528        .expect("seed schema");
10529
10530        let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
10531        assert!(rowid.is_none(), "ambiguous match must not correlate");
10532    }
10533
10534    #[test]
10535    fn lookup_unique_normalized_identity_requires_publisher_intersect() {
10536        // norm_name has multiple matches but only one shares its
10537        // norm_publisher with the installed package. Winget's intersect
10538        // logic still picks the right one — verify.
10539        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10540        execute_batch_sql(
10541            &connection,
10542            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10543             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10544             INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
10545             INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('microsoft', 200);",
10546        )
10547        .expect("seed schema");
10548
10549        let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
10550        assert_eq!(rowid, Some(100));
10551    }
10552
10553    #[test]
10554    fn lookup_unique_normalized_identity_misses_when_publisher_does_not_match() {
10555        // Name matches but no publisher row for that package id —
10556        // intersection is empty so we don't correlate.
10557        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10558        execute_batch_sql(
10559            &connection,
10560            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10561             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10562             INSERT INTO norm_names2 VALUES ('foo', 100);\n\
10563             INSERT INTO norm_publishers2 VALUES ('bar', 200);",
10564        )
10565        .expect("seed schema");
10566
10567        let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query");
10568        assert!(
10569            rowid.is_none(),
10570            "name matches package 100 but publisher matches 200 — no intersection"
10571        );
10572    }
10573
10574    #[test]
10575    fn parse_yaml_manifest_bundle_returns_singleton_document() {
10576        let yaml = r#"
10577PackageIdentifier: Test.Package
10578PackageVersion: 1.2.3
10579DefaultLocale: en-US
10580ManifestType: version
10581ManifestVersion: 1.10.0
10582---
10583PackageIdentifier: Test.Package
10584PackageVersion: 1.2.3
10585PackageLocale: en-US
10586PackageName: Test Package
10587Publisher: Example
10588License: MIT
10589ShortDescription: Structured output
10590ManifestType: defaultLocale
10591ManifestVersion: 1.10.0
10592---
10593PackageIdentifier: Test.Package
10594PackageVersion: 1.2.3
10595ManifestType: installer
10596ManifestVersion: 1.10.0
10597Installers:
10598  - Architecture: x64
10599    InstallerType: exe
10600    InstallerUrl: https://example.test/Test.Package.exe
10601    InstallerSha256: ABC123
10602"#;
10603
10604        let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
10605
10606        assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
10607        assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
10608        assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
10609    }
10610
10611    #[test]
10612    fn collapse_structured_documents_returns_plural_show_documents() {
10613        let documents = collapse_structured_documents(&[
10614            JsonValue::Array(vec![
10615                serde_json::json!({
10616                    "PackageIdentifier": "Test.Package.One",
10617                    "PackageVersion": "1.0.0",
10618                    "DefaultLocale": "en-US",
10619                    "ManifestType": "version",
10620                    "ManifestVersion": "1.10.0"
10621                }),
10622                serde_json::json!({
10623                    "PackageIdentifier": "Test.Package.One",
10624                    "PackageVersion": "1.0.0",
10625                    "PackageLocale": "en-US",
10626                    "PackageName": "Test Package One",
10627                    "ManifestType": "defaultLocale",
10628                    "ManifestVersion": "1.10.0"
10629                }),
10630                serde_json::json!({
10631                    "PackageIdentifier": "Test.Package.One",
10632                    "PackageVersion": "1.0.0",
10633                    "ManifestType": "installer",
10634                    "ManifestVersion": "1.10.0",
10635                    "Installers": [
10636                        {
10637                            "Architecture": "x64",
10638                            "InstallerType": "exe",
10639                            "InstallerUrl": "https://example.test/one.exe",
10640                            "InstallerSha256": "ABC123"
10641                        }
10642                    ]
10643                }),
10644            ]),
10645            serde_json::json!({
10646                "PackageIdentifier": "Test.Package.Two",
10647                "PackageVersion": "2.0.0",
10648                "PackageLocale": "en-US",
10649                "PackageName": "Test Package Two",
10650                "ManifestType": "singleton",
10651                "ManifestVersion": "1.12.0"
10652            }),
10653        ]);
10654
10655        assert_eq!(documents.len(), 2);
10656        assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
10657        assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
10658        assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
10659        assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
10660        assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
10661    }
10662
10663    #[test]
10664    fn unsupported_action_result_marks_noop_and_warning() {
10665        let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
10666
10667        assert!(result.success);
10668        assert!(result.no_op);
10669        assert_eq!(result.exit_code, 0);
10670        assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
10671    }
10672
10673    #[test]
10674    fn create_install_no_op_result_honors_no_upgrade() {
10675        let mut request = InstallRequest::new(PackageQuery {
10676            id: Some("Contoso.App".to_owned()),
10677            ..PackageQuery::default()
10678        });
10679        request.no_upgrade = true;
10680
10681        let manifest = Manifest {
10682            id: "Contoso.App".to_owned(),
10683            name: "Contoso App".to_owned(),
10684            version: "2.0.0".to_owned(),
10685            channel: String::new(),
10686            publisher: None,
10687            description: None,
10688            moniker: None,
10689            package_url: None,
10690            publisher_url: None,
10691            publisher_support_url: None,
10692            license: None,
10693            license_url: None,
10694            privacy_url: None,
10695            author: None,
10696            copyright: None,
10697            copyright_url: None,
10698            release_notes: None,
10699            release_notes_url: None,
10700            tags: Vec::new(),
10701            agreements: Vec::new(),
10702            package_dependencies: Vec::new(),
10703            documentation: Vec::new(),
10704            icons: Vec::new(),
10705            installers: Vec::new(),
10706            require_explicit_upgrade: false,
10707        };
10708        let existing = ListMatch {
10709            name: "Contoso App".to_owned(),
10710            id: "Contoso.App".to_owned(),
10711            local_id: "Contoso.App".to_owned(),
10712            installed_version: "1.0.0".to_owned(),
10713            available_version: Some("2.0.0".to_owned()),
10714            source_name: Some("winget".to_owned()),
10715            publisher: None,
10716            scope: None,
10717            installer_category: None,
10718            install_location: None,
10719            package_family_names: Vec::new(),
10720            product_codes: Vec::new(),
10721            upgrade_codes: Vec::new(),
10722        };
10723
10724        let result =
10725            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
10726
10727        assert!(result.success);
10728        assert!(result.no_op);
10729        assert_eq!(result.version, "1.0.0");
10730        assert_eq!(
10731            result.warnings,
10732            vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
10733        );
10734    }
10735
10736    #[test]
10737    fn create_install_no_op_result_skips_reinstall_when_current() {
10738        let request = InstallRequest::new(PackageQuery {
10739            id: Some("Contoso.App".to_owned()),
10740            ..PackageQuery::default()
10741        });
10742
10743        let manifest = Manifest {
10744            id: "Contoso.App".to_owned(),
10745            name: "Contoso App".to_owned(),
10746            version: "2.0.0".to_owned(),
10747            channel: String::new(),
10748            publisher: None,
10749            description: None,
10750            moniker: None,
10751            package_url: None,
10752            publisher_url: None,
10753            publisher_support_url: None,
10754            license: None,
10755            license_url: None,
10756            privacy_url: None,
10757            author: None,
10758            copyright: None,
10759            copyright_url: None,
10760            release_notes: None,
10761            release_notes_url: None,
10762            tags: Vec::new(),
10763            agreements: Vec::new(),
10764            package_dependencies: Vec::new(),
10765            documentation: Vec::new(),
10766            icons: Vec::new(),
10767            installers: Vec::new(),
10768            require_explicit_upgrade: false,
10769        };
10770        let existing = ListMatch {
10771            name: "Contoso App".to_owned(),
10772            id: "Contoso.App".to_owned(),
10773            local_id: "Contoso.App".to_owned(),
10774            installed_version: "2.0.0".to_owned(),
10775            available_version: Some("2.0.0".to_owned()),
10776            source_name: Some("winget".to_owned()),
10777            publisher: None,
10778            scope: None,
10779            installer_category: None,
10780            install_location: None,
10781            package_family_names: Vec::new(),
10782            product_codes: Vec::new(),
10783            upgrade_codes: Vec::new(),
10784        };
10785
10786        let result =
10787            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
10788
10789        assert!(result.success);
10790        assert!(result.no_op);
10791        assert_eq!(result.version, "2.0.0");
10792        assert_eq!(
10793            result.warnings,
10794            vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
10795        );
10796    }
10797
10798    #[test]
10799    fn collapse_structured_document_projects_merged_manifest() {
10800        let document = collapse_structured_document(&serde_json::json!({
10801            "PackageIdentifier": "Test.Package",
10802            "PackageVersion": "1.2.3",
10803            "PackageLocale": "en-US",
10804            "PackageName": "Test Package",
10805            "Publisher": "Example",
10806            "InstallerType": "exe",
10807            "Installers": [
10808                {
10809                    "Architecture": "x64",
10810                    "InstallerUrl": "https://example.test/Test.Package.exe",
10811                    "InstallerSha256": "ABC123"
10812                }
10813            ],
10814            "ManifestType": "merged",
10815            "ManifestVersion": "1.10.0"
10816        }));
10817
10818        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
10819        assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
10820        assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
10821        assert_eq!(document["InstallerType"].as_str(), Some("exe"));
10822        assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
10823    }
10824
10825    #[test]
10826    fn parses_fixture_manifest() {
10827        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
10828            .join("tests")
10829            .join("fixtures")
10830            .join("ManifestV1_28-Singleton.yaml");
10831        let bytes = fs::read(root).expect("fixture bytes");
10832        let manifest = parse_yaml_manifest(&bytes).expect("manifest");
10833        assert_eq!(manifest.id, "microsoft.msixsdk");
10834        assert_eq!(manifest.version, "1.7.32");
10835        assert_eq!(manifest.name, "MSIX SDK");
10836        assert!(!manifest.installers.is_empty());
10837    }
10838
10839    #[test]
10840    fn parses_installer_switches_from_yaml_manifest() {
10841        let yaml = r#"
10842PackageIdentifier: Test.Package
10843PackageVersion: 1.2.3
10844PackageName: Test Package
10845InstallerSwitches:
10846  SilentWithProgress: /SILENT
10847Installers:
10848  - Architecture: x64
10849    InstallerType: inno
10850    InstallerUrl: https://example.test/Test.Package.exe
10851    InstallerSha256: ABC123
10852    InstallerSwitches:
10853      Silent: /VERYSILENT
10854      Interactive: /HELP
10855"#;
10856
10857        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
10858        let installer = &manifest.installers[0];
10859
10860        assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
10861        assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
10862        assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
10863    }
10864
10865    #[test]
10866    fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
10867        let yaml = r#"
10868PackageIdentifier: Test.Package
10869PackageVersion: 1.2.3
10870PackageName: Test Package
10871Platform:
10872  - Windows.Desktop
10873MinimumOSVersion: 10.0.19041.0
10874Installers:
10875  - Architecture: x64
10876    InstallerType: exe
10877    InstallerUrl: https://example.test/Test.Package.exe
10878    InstallerSha256: ABC123
10879  - Architecture: x64
10880    InstallerType: msix
10881    Platform:
10882      - Windows.Universal
10883    MinimumOSVersion: 10.0.22621.0
10884    InstallerUrl: https://example.test/Test.Package.msix
10885    InstallerSha256: DEF456
10886"#;
10887
10888        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
10889
10890        assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
10891        assert_eq!(
10892            manifest.installers[0].minimum_os_version.as_deref(),
10893            Some("10.0.19041.0")
10894        );
10895        assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
10896        assert_eq!(
10897            manifest.installers[1].minimum_os_version.as_deref(),
10898            Some("10.0.22621.0")
10899        );
10900    }
10901
10902    #[test]
10903    fn installer_switch_arguments_prefer_manifest_switches() {
10904        let installer = Installer {
10905            architecture: None,
10906            installer_type: Some("inno".to_owned()),
10907            url: None,
10908            sha256: None,
10909            product_code: None,
10910            locale: None,
10911            scope: None,
10912            release_date: None,
10913            package_family_name: None,
10914            upgrade_code: None,
10915            platforms: Vec::new(),
10916            minimum_os_version: None,
10917            switches: InstallerSwitches {
10918                silent: Some("/mysilent".to_owned()),
10919                silent_with_progress: Some("/mysilentwithprogress".to_owned()),
10920                interactive: Some("/myinteractive".to_owned()),
10921                ..InstallerSwitches::default()
10922            },
10923            commands: Vec::new(),
10924            package_dependencies: Vec::new(),
10925            require_explicit_upgrade: false,
10926            nested_installer_type: None,
10927            nested_installer_files: Vec::new(),
10928        };
10929        let manifest = Manifest {
10930            id: "Test.Package".to_owned(),
10931            name: "Test Package".to_owned(),
10932            version: "1.0.0".to_owned(),
10933            channel: String::new(),
10934            publisher: None,
10935            description: None,
10936            moniker: None,
10937            package_url: None,
10938            publisher_url: None,
10939            publisher_support_url: None,
10940            license: None,
10941            license_url: None,
10942            privacy_url: None,
10943            author: None,
10944            copyright: None,
10945            copyright_url: None,
10946            release_notes: None,
10947            release_notes_url: None,
10948            tags: Vec::new(),
10949            agreements: Vec::new(),
10950            package_dependencies: Vec::new(),
10951            documentation: Vec::new(),
10952            icons: Vec::new(),
10953            installers: Vec::new(),
10954            require_explicit_upgrade: false,
10955        };
10956
10957        let mut silent_request = InstallRequest::new(PackageQuery::default());
10958        silent_request.mode = InstallerMode::Silent;
10959        let mut progress_request = InstallRequest::new(PackageQuery::default());
10960        progress_request.mode = InstallerMode::SilentWithProgress;
10961        let mut interactive_request = InstallRequest::new(PackageQuery::default());
10962        interactive_request.mode = InstallerMode::Interactive;
10963        assert_eq!(
10964            installer_command_arguments(
10965                "inno",
10966                &silent_request,
10967                &manifest,
10968                Path::new("installer.exe"),
10969                &installer
10970            ),
10971            vec!["/mysilent".to_owned()]
10972        );
10973        assert_eq!(
10974            installer_command_arguments(
10975                "inno",
10976                &progress_request,
10977                &manifest,
10978                Path::new("installer.exe"),
10979                &installer
10980            ),
10981            vec!["/mysilentwithprogress".to_owned()]
10982        );
10983        assert_eq!(
10984            installer_command_arguments(
10985                "inno",
10986                &interactive_request,
10987                &manifest,
10988                Path::new("installer.exe"),
10989                &installer
10990            ),
10991            vec!["/myinteractive".to_owned()]
10992        );
10993    }
10994
10995    #[test]
10996    fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
10997        let installer = Installer {
10998            architecture: None,
10999            installer_type: Some("inno".to_owned()),
11000            url: None,
11001            sha256: None,
11002            product_code: None,
11003            locale: None,
11004            scope: None,
11005            release_date: None,
11006            package_family_name: None,
11007            upgrade_code: None,
11008            platforms: Vec::new(),
11009            minimum_os_version: None,
11010            switches: InstallerSwitches::default(),
11011            commands: Vec::new(),
11012            package_dependencies: Vec::new(),
11013            require_explicit_upgrade: false,
11014            nested_installer_type: None,
11015            nested_installer_files: Vec::new(),
11016        };
11017        let manifest = Manifest {
11018            id: "Test.Package".to_owned(),
11019            name: "Test Package".to_owned(),
11020            version: "1.0.0".to_owned(),
11021            channel: String::new(),
11022            publisher: None,
11023            description: None,
11024            moniker: None,
11025            package_url: None,
11026            publisher_url: None,
11027            publisher_support_url: None,
11028            license: None,
11029            license_url: None,
11030            privacy_url: None,
11031            author: None,
11032            copyright: None,
11033            copyright_url: None,
11034            release_notes: None,
11035            release_notes_url: None,
11036            tags: Vec::new(),
11037            agreements: Vec::new(),
11038            package_dependencies: Vec::new(),
11039            documentation: Vec::new(),
11040            icons: Vec::new(),
11041            installers: Vec::new(),
11042            require_explicit_upgrade: false,
11043        };
11044        let mut progress_request = InstallRequest::new(PackageQuery::default());
11045        progress_request.mode = InstallerMode::SilentWithProgress;
11046        let mut silent_request = InstallRequest::new(PackageQuery::default());
11047        silent_request.mode = InstallerMode::Silent;
11048
11049        assert_eq!(
11050            installer_command_arguments(
11051                "inno",
11052                &progress_request,
11053                &manifest,
11054                Path::new("installer.exe"),
11055                &installer
11056            ),
11057            vec![
11058                "/SP-".to_owned(),
11059                "/SILENT".to_owned(),
11060                "/SUPPRESSMSGBOXES".to_owned(),
11061                "/NORESTART".to_owned()
11062            ]
11063        );
11064        assert_eq!(
11065            installer_command_arguments(
11066                "inno",
11067                &silent_request,
11068                &manifest,
11069                Path::new("installer.exe"),
11070                &installer
11071            ),
11072            vec![
11073                "/SP-".to_owned(),
11074                "/VERYSILENT".to_owned(),
11075                "/SUPPRESSMSGBOXES".to_owned(),
11076                "/NORESTART".to_owned()
11077            ]
11078        );
11079    }
11080
11081    #[test]
11082    fn installer_command_arguments_append_manifest_and_cli_switches() {
11083        let installer = Installer {
11084            architecture: None,
11085            installer_type: Some("msi".to_owned()),
11086            url: None,
11087            sha256: None,
11088            product_code: None,
11089            locale: None,
11090            scope: None,
11091            release_date: None,
11092            package_family_name: None,
11093            upgrade_code: None,
11094            platforms: Vec::new(),
11095            minimum_os_version: None,
11096            switches: InstallerSwitches {
11097                custom: Some("ADDLOCAL=Core".to_owned()),
11098                log: Some("/log \"<LOGPATH>\"".to_owned()),
11099                install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
11100                ..InstallerSwitches::default()
11101            },
11102            commands: Vec::new(),
11103            package_dependencies: Vec::new(),
11104            require_explicit_upgrade: false,
11105            nested_installer_type: None,
11106            nested_installer_files: Vec::new(),
11107        };
11108        let manifest = Manifest {
11109            id: "ShareX.ShareX".to_owned(),
11110            name: "ShareX".to_owned(),
11111            version: "19.0.2".to_owned(),
11112            channel: String::new(),
11113            publisher: None,
11114            description: None,
11115            moniker: None,
11116            package_url: None,
11117            publisher_url: None,
11118            publisher_support_url: None,
11119            license: None,
11120            license_url: None,
11121            privacy_url: None,
11122            author: None,
11123            copyright: None,
11124            copyright_url: None,
11125            release_notes: None,
11126            release_notes_url: None,
11127            tags: Vec::new(),
11128            agreements: Vec::new(),
11129            package_dependencies: Vec::new(),
11130            documentation: Vec::new(),
11131            icons: Vec::new(),
11132            installers: Vec::new(),
11133            require_explicit_upgrade: false,
11134        };
11135        let mut request = InstallRequest::new(PackageQuery::default());
11136        request.mode = InstallerMode::Silent;
11137        request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
11138        request.custom = Some("REBOOT=ReallySuppress".to_owned());
11139        request.install_location = Some(r"C:\Apps\ShareX".to_owned());
11140
11141        assert_eq!(
11142            installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
11143            vec![
11144                "/i".to_owned(),
11145                r"C:\temp\ShareX.msi".to_owned(),
11146                "/quiet".to_owned(),
11147                "/norestart".to_owned(),
11148                "/log".to_owned(),
11149                r"C:\temp\winget.log".to_owned(),
11150                "ADDLOCAL=Core".to_owned(),
11151                "REBOOT=ReallySuppress".to_owned(),
11152                r"TARGETDIR=C:\Apps\ShareX".to_owned(),
11153            ]
11154        );
11155    }
11156
11157    #[test]
11158    fn decompresses_mszyml_payload() {
11159        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
11160        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
11161        encoder.write_all(payload.as_bytes()).expect("write payload");
11162        let compressed = encoder.finish().expect("finish payload");
11163        let mut mszip = b"CK".to_vec();
11164        mszip.extend_from_slice(&compressed);
11165
11166        let decompressed = decompress_mszyml(&mszip).expect("decompress");
11167        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
11168        assert_eq!(parsed.versions[0].version, "1.2.3");
11169    }
11170
11171    #[test]
11172    fn decompresses_mszyml_payload_with_prefix() {
11173        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
11174        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
11175        encoder.write_all(payload.as_bytes()).expect("write payload");
11176        let compressed = encoder.finish().expect("finish payload");
11177        let mut prefixed = vec![0u8; 28];
11178        prefixed.extend_from_slice(b"CK");
11179        prefixed.extend_from_slice(&compressed);
11180
11181        let decompressed = decompress_mszyml(&prefixed).expect("decompress");
11182        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
11183        assert_eq!(parsed.versions[0].version, "1.2.3");
11184    }
11185
11186    #[test]
11187    fn compares_versions_naturally() {
11188        assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
11189        assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
11190        assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
11191    }
11192
11193    #[test]
11194    fn installer_hash_verification_fails_on_mismatch_without_override() {
11195        let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
11196        assert!(result.is_err());
11197        assert!(
11198            result
11199                .expect_err("hash mismatch")
11200                .to_string()
11201                .contains("Installer hash mismatch")
11202        );
11203    }
11204
11205    #[test]
11206    fn installer_hash_verification_allows_mismatch_with_override() {
11207        verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
11208        verify_installer_hash(None, b"pinget", false).expect("missing hash");
11209    }
11210
11211    #[test]
11212    fn search_query_many_includes_tag_and_command_conditions() {
11213        let query = PackageQuery {
11214            query: Some("terminal".to_owned()),
11215            ..PackageQuery::default()
11216        };
11217
11218        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
11219
11220        assert!(where_clause.contains("tags2"));
11221        assert!(where_clause.contains("commands2"));
11222        assert_eq!(params.len(), 5);
11223    }
11224
11225    #[test]
11226    fn tag_and_command_conditions_are_non_correlated() {
11227        // The tag/command map lookups must be emitted as non-correlated
11228        // `rowid IN (SELECT ...)` subqueries. A correlated `EXISTS (... WHERE
11229        // map.owner = packages.rowid ...)` re-scans the un-indexed map tables
11230        // per package row, which is quadratic (~19s on the real winget index
11231        // under SQLite, and an effective hang under Turso). Guard against a
11232        // regression back to the correlated form.
11233        let query = PackageQuery {
11234            query: Some("terminal".to_owned()),
11235            ..PackageQuery::default()
11236        };
11237
11238        let (where_clause, _params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
11239
11240        assert!(where_clause.contains("packages.rowid IN (SELECT"));
11241        assert!(!where_clause.contains("EXISTS"));
11242        assert!(!where_clause.contains("= packages.rowid"));
11243    }
11244
11245    #[test]
11246    fn show_query_single_omits_tag_and_command_conditions() {
11247        let query = PackageQuery {
11248            query: Some("terminal".to_owned()),
11249            ..PackageQuery::default()
11250        };
11251
11252        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
11253
11254        assert!(!where_clause.contains("tags2"));
11255        assert!(!where_clause.contains("commands2"));
11256        assert_eq!(params.len(), 3);
11257    }
11258
11259    #[test]
11260    fn explicit_tag_filter_uses_exact_match() {
11261        let query = PackageQuery {
11262            tag: Some("terminal".to_owned()),
11263            ..PackageQuery::default()
11264        };
11265
11266        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
11267
11268        assert!(where_clause.contains("tags2"));
11269        assert_eq!(params, vec!["terminal".to_owned()]);
11270    }
11271
11272    #[test]
11273    fn rest_tag_filter_uses_exact_match() {
11274        let query = PackageQuery {
11275            tag: Some("terminal".to_owned()),
11276            ..PackageQuery::default()
11277        };
11278        let info = RestInformation {
11279            required_package_match_fields: Vec::new(),
11280            unsupported_package_match_fields: Vec::new(),
11281            required_query_parameters: Vec::new(),
11282            ..RestInformation::default()
11283        };
11284
11285        let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
11286        let filters = body["Filters"].as_array().expect("filters");
11287
11288        assert_eq!(filters.len(), 1);
11289        assert_eq!(filters[0]["PackageMatchField"], "Tag");
11290        assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
11291        assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
11292    }
11293
11294    #[test]
11295    fn selects_installer_using_requested_filters() {
11296        let installers = vec![
11297            Installer {
11298                architecture: Some("x86".to_owned()),
11299                installer_type: Some("zip".to_owned()),
11300                url: None,
11301                sha256: None,
11302                product_code: None,
11303                locale: Some("en-US".to_owned()),
11304                scope: Some("user".to_owned()),
11305                release_date: None,
11306                package_family_name: None,
11307                upgrade_code: None,
11308                platforms: Vec::new(),
11309                minimum_os_version: None,
11310                switches: InstallerSwitches::default(),
11311                commands: Vec::new(),
11312                package_dependencies: Vec::new(),
11313                require_explicit_upgrade: false,
11314                nested_installer_type: None,
11315                nested_installer_files: Vec::new(),
11316            },
11317            Installer {
11318                architecture: Some("x64".to_owned()),
11319                installer_type: Some("msix".to_owned()),
11320                url: None,
11321                sha256: None,
11322                product_code: None,
11323                locale: Some("en-US".to_owned()),
11324                scope: Some("user".to_owned()),
11325                release_date: None,
11326                package_family_name: None,
11327                upgrade_code: None,
11328                platforms: Vec::new(),
11329                minimum_os_version: None,
11330                switches: InstallerSwitches::default(),
11331                commands: vec!["demo".to_owned()],
11332                package_dependencies: Vec::new(),
11333                require_explicit_upgrade: false,
11334                nested_installer_type: None,
11335                nested_installer_files: Vec::new(),
11336            },
11337        ];
11338        let query = PackageQuery {
11339            installer_type: Some("msix".to_owned()),
11340            installer_architecture: Some("x64".to_owned()),
11341            install_scope: Some("user".to_owned()),
11342            locale: Some("en-US".to_owned()),
11343            ..PackageQuery::default()
11344        };
11345
11346        let selected = select_installer(&installers, &query).expect("selected installer");
11347        assert_eq!(selected.installer_type.as_deref(), Some("msix"));
11348        assert_eq!(selected.architecture.as_deref(), Some("x64"));
11349    }
11350
11351    #[test]
11352    fn selects_first_installer_when_rank_ties() {
11353        let installers = vec![
11354            Installer {
11355                architecture: Some("x64".to_owned()),
11356                installer_type: Some("exe".to_owned()),
11357                url: None,
11358                sha256: None,
11359                product_code: None,
11360                locale: Some("en-US".to_owned()),
11361                scope: Some("machine".to_owned()),
11362                release_date: None,
11363                package_family_name: None,
11364                upgrade_code: None,
11365                platforms: Vec::new(),
11366                minimum_os_version: None,
11367                switches: InstallerSwitches::default(),
11368                commands: Vec::new(),
11369                package_dependencies: Vec::new(),
11370                require_explicit_upgrade: false,
11371                nested_installer_type: None,
11372                nested_installer_files: Vec::new(),
11373            },
11374            Installer {
11375                architecture: Some("x64".to_owned()),
11376                installer_type: Some("exe".to_owned()),
11377                url: None,
11378                sha256: None,
11379                product_code: None,
11380                locale: Some("en-US".to_owned()),
11381                scope: Some("user".to_owned()),
11382                release_date: None,
11383                package_family_name: None,
11384                upgrade_code: None,
11385                platforms: Vec::new(),
11386                minimum_os_version: None,
11387                switches: InstallerSwitches::default(),
11388                commands: Vec::new(),
11389                package_dependencies: Vec::new(),
11390                require_explicit_upgrade: false,
11391                nested_installer_type: None,
11392                nested_installer_files: Vec::new(),
11393            },
11394        ];
11395
11396        let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
11397        assert_eq!(selected.scope.as_deref(), Some("machine"));
11398    }
11399
11400    #[test]
11401    fn selects_installer_using_requested_platform() {
11402        let installers = vec![
11403            Installer {
11404                architecture: Some("x64".to_owned()),
11405                installer_type: Some("exe".to_owned()),
11406                url: None,
11407                sha256: None,
11408                product_code: None,
11409                locale: Some("en-US".to_owned()),
11410                scope: Some("user".to_owned()),
11411                release_date: None,
11412                package_family_name: None,
11413                upgrade_code: None,
11414                platforms: vec!["Windows.Universal".to_owned()],
11415                minimum_os_version: None,
11416                switches: InstallerSwitches::default(),
11417                commands: Vec::new(),
11418                package_dependencies: Vec::new(),
11419                require_explicit_upgrade: false,
11420                nested_installer_type: None,
11421                nested_installer_files: Vec::new(),
11422            },
11423            Installer {
11424                architecture: Some("x64".to_owned()),
11425                installer_type: Some("exe".to_owned()),
11426                url: None,
11427                sha256: None,
11428                product_code: None,
11429                locale: Some("en-US".to_owned()),
11430                scope: Some("user".to_owned()),
11431                release_date: None,
11432                package_family_name: None,
11433                upgrade_code: None,
11434                platforms: vec!["Windows.Desktop".to_owned()],
11435                minimum_os_version: None,
11436                switches: InstallerSwitches::default(),
11437                commands: Vec::new(),
11438                package_dependencies: Vec::new(),
11439                require_explicit_upgrade: false,
11440                nested_installer_type: None,
11441                nested_installer_files: Vec::new(),
11442            },
11443        ];
11444        let query = PackageQuery {
11445            installer_type: Some("exe".to_owned()),
11446            platform: Some("Windows.Desktop".to_owned()),
11447            ..PackageQuery::default()
11448        };
11449
11450        let selected = select_installer(&installers, &query).expect("selected installer");
11451        assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
11452    }
11453
11454    #[test]
11455    fn selects_installer_using_requested_os_version() {
11456        let installers = vec![
11457            Installer {
11458                architecture: Some("x64".to_owned()),
11459                installer_type: Some("exe".to_owned()),
11460                url: None,
11461                sha256: None,
11462                product_code: None,
11463                locale: Some("en-US".to_owned()),
11464                scope: Some("user".to_owned()),
11465                release_date: None,
11466                package_family_name: None,
11467                upgrade_code: None,
11468                platforms: Vec::new(),
11469                minimum_os_version: Some("10.0.22621.0".to_owned()),
11470                switches: InstallerSwitches::default(),
11471                commands: Vec::new(),
11472                package_dependencies: Vec::new(),
11473                require_explicit_upgrade: false,
11474                nested_installer_type: None,
11475                nested_installer_files: Vec::new(),
11476            },
11477            Installer {
11478                architecture: Some("x64".to_owned()),
11479                installer_type: Some("exe".to_owned()),
11480                url: None,
11481                sha256: None,
11482                product_code: None,
11483                locale: Some("en-US".to_owned()),
11484                scope: Some("user".to_owned()),
11485                release_date: None,
11486                package_family_name: None,
11487                upgrade_code: None,
11488                platforms: Vec::new(),
11489                minimum_os_version: Some("10.0.19041.0".to_owned()),
11490                switches: InstallerSwitches::default(),
11491                commands: Vec::new(),
11492                package_dependencies: Vec::new(),
11493                require_explicit_upgrade: false,
11494                nested_installer_type: None,
11495                nested_installer_files: Vec::new(),
11496            },
11497        ];
11498        let query = PackageQuery {
11499            installer_type: Some("exe".to_owned()),
11500            os_version: Some("10.0.19045.0".to_owned()),
11501            ..PackageQuery::default()
11502        };
11503
11504        let selected = select_installer(&installers, &query).expect("selected installer");
11505        assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
11506    }
11507
11508    #[test]
11509    fn derives_correlation_name_candidates() {
11510        let candidates = correlation_name_candidates("PowerToys (Preview) x64");
11511        assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
11512        assert!(candidates.contains(&"PowerToys".to_owned()));
11513    }
11514
11515    #[test]
11516    fn correlates_installed_package_to_available_match() {
11517        let installed = InstalledPackage {
11518            name: "PowerToys (Preview) x64".to_owned(),
11519            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
11520            installed_version: "0.98.1".to_owned(),
11521            publisher: None,
11522            scope: Some("Machine".to_owned()),
11523            installer_category: Some("exe".to_owned()),
11524            install_location: None,
11525            package_family_names: Vec::new(),
11526            product_codes: Vec::new(),
11527            upgrade_codes: Vec::new(),
11528            correlated: None,
11529            installed_version_canonical: false,
11530            correlated_requires_explicit_upgrade: false,
11531            correlated_lacks_compatible_installer: false,
11532        };
11533        let candidates = vec![SearchMatch {
11534            source_name: "winget".to_owned(),
11535            source_kind: SourceKind::PreIndexed,
11536            id: "Microsoft.PowerToys".to_owned(),
11537            name: "PowerToys".to_owned(),
11538            moniker: None,
11539            version: Some("0.98.1".to_owned()),
11540            channel: None,
11541            match_criteria: None,
11542        }];
11543
11544        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
11545        assert_eq!(correlated.id, "Microsoft.PowerToys");
11546    }
11547
11548    #[test]
11549    fn list_query_uses_available_lookup_for_tag_filters() {
11550        let query = ListQuery {
11551            tag: Some("terminal".to_owned()),
11552            ..ListQuery::default()
11553        };
11554
11555        assert!(list_query_needs_available_lookup(&query));
11556    }
11557
11558    #[test]
11559    fn list_tag_filter_requires_correlated_match() {
11560        let package = InstalledPackage {
11561            name: "PowerToys (Preview) x64".to_owned(),
11562            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
11563            installed_version: "0.98.1".to_owned(),
11564            publisher: None,
11565            scope: Some("Machine".to_owned()),
11566            installer_category: Some("exe".to_owned()),
11567            install_location: None,
11568            package_family_names: Vec::new(),
11569            product_codes: Vec::new(),
11570            upgrade_codes: Vec::new(),
11571            correlated: None,
11572            installed_version_canonical: false,
11573            correlated_requires_explicit_upgrade: false,
11574            correlated_lacks_compatible_installer: false,
11575        };
11576        let query = ListQuery {
11577            tag: Some("powertoys".to_owned()),
11578            ..ListQuery::default()
11579        };
11580
11581        assert!(!list_package_matches(&package, &query));
11582    }
11583
11584    #[test]
11585    fn list_lookup_ignores_display_count() {
11586        let query = ListQuery {
11587            query: Some("git".to_owned()),
11588            count: Some(5),
11589            ..ListQuery::default()
11590        };
11591
11592        let package_query = package_query_from_list_query(&query);
11593        assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
11594    }
11595
11596    #[test]
11597    fn correlation_tiebreaks_on_unnormalized_name_prefix() {
11598        // `Notepad++` and `Notepad--` both normalize to `notepad` (alphanumeric filter
11599        // strips the trailing punctuation). Without a tiebreaker, max_by_key returned
11600        // whichever candidate happened to come last in iteration order. The installed
11601        // display name's prefix decides the correct one.
11602        let installed = InstalledPackage {
11603            name: "Notepad++ (ARM 64-bit)".to_owned(),
11604            local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
11605            installed_version: "8.8.9".to_owned(),
11606            publisher: None,
11607            scope: Some("Machine".to_owned()),
11608            installer_category: Some("exe".to_owned()),
11609            install_location: None,
11610            package_family_names: Vec::new(),
11611            product_codes: Vec::new(),
11612            upgrade_codes: Vec::new(),
11613            correlated: None,
11614            installed_version_canonical: false,
11615            correlated_requires_explicit_upgrade: false,
11616            correlated_lacks_compatible_installer: false,
11617        };
11618        let candidates = vec![
11619            SearchMatch {
11620                source_name: "winget".to_owned(),
11621                source_kind: SourceKind::PreIndexed,
11622                id: "ndd.Notepad--".to_owned(),
11623                name: "Notepad--".to_owned(),
11624                moniker: None,
11625                version: Some("1.0.0".to_owned()),
11626                channel: None,
11627                match_criteria: None,
11628            },
11629            SearchMatch {
11630                source_name: "winget".to_owned(),
11631                source_kind: SourceKind::PreIndexed,
11632                id: "Notepad++.Notepad++".to_owned(),
11633                name: "Notepad++".to_owned(),
11634                moniker: None,
11635                version: Some("8.9.5".to_owned()),
11636                channel: None,
11637                match_criteria: None,
11638            },
11639        ];
11640
11641        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
11642        assert_eq!(correlated.id, "Notepad++.Notepad++");
11643    }
11644
11645    #[test]
11646    fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
11647        // Without the prefix bonus, the loose substring match picked up `Studio`
11648        // anywhere in the installed name — so `ZeroBrane.Studio` would outrank the
11649        // genuinely-related `Microsoft.DotNet.SDK.10` because both scored 700 and
11650        // sort order favored the alphabetically-later candidate.
11651        let installed = InstalledPackage {
11652            name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
11653            local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
11654            installed_version: "40.10.18029".to_owned(),
11655            publisher: None,
11656            scope: Some("Machine".to_owned()),
11657            installer_category: Some("msi".to_owned()),
11658            install_location: None,
11659            package_family_names: Vec::new(),
11660            product_codes: Vec::new(),
11661            upgrade_codes: Vec::new(),
11662            correlated: None,
11663            installed_version_canonical: false,
11664            correlated_requires_explicit_upgrade: false,
11665            correlated_lacks_compatible_installer: false,
11666        };
11667        let candidates = vec![
11668            SearchMatch {
11669                source_name: "winget".to_owned(),
11670                source_kind: SourceKind::PreIndexed,
11671                id: "ZeroBrane.Studio".to_owned(),
11672                name: "Studio".to_owned(),
11673                moniker: None,
11674                version: Some("1.0".to_owned()),
11675                channel: None,
11676                match_criteria: None,
11677            },
11678            SearchMatch {
11679                source_name: "winget".to_owned(),
11680                source_kind: SourceKind::PreIndexed,
11681                id: "BrickLink.Studio".to_owned(),
11682                name: "Studio".to_owned(),
11683                moniker: None,
11684                version: Some("1.0".to_owned()),
11685                channel: None,
11686                match_criteria: None,
11687            },
11688            SearchMatch {
11689                source_name: "winget".to_owned(),
11690                source_kind: SourceKind::PreIndexed,
11691                id: "Microsoft.DotNet.SDK.10".to_owned(),
11692                name: "Microsoft .NET SDK 10.0".to_owned(),
11693                moniker: None,
11694                version: Some("10.0.204".to_owned()),
11695                channel: None,
11696                match_criteria: None,
11697            },
11698        ];
11699
11700        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
11701        assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
11702    }
11703
11704    #[test]
11705    fn msix_packages_do_not_correlate_via_name() {
11706        // MSIX correlation must go through the v2 index's `pfns2` table — name
11707        // fallback is wrong because two MSIX packages can legitimately share a
11708        // display name without sharing identity (`Microsoft Edge Stable` MSIX
11709        // vs the catalog `Microsoft.Edge` MSI, `Notepad++` Store stub MSIX vs
11710        // the catalog Inno installer). Even when the names match exactly the
11711        // catalog `Microsoft.Teams` doesn't represent the same MSIX without a
11712        // PFN hit; that lookup happens in `correlate_installed_via_index`.
11713        let installed = InstalledPackage {
11714            name: "Microsoft Teams".to_owned(),
11715            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
11716            installed_version: "25290.205.4069.4894".to_owned(),
11717            publisher: None,
11718            scope: Some("User".to_owned()),
11719            installer_category: Some("msix".to_owned()),
11720            install_location: None,
11721            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
11722            product_codes: Vec::new(),
11723            upgrade_codes: Vec::new(),
11724            correlated: None,
11725            installed_version_canonical: false,
11726            correlated_requires_explicit_upgrade: false,
11727            correlated_lacks_compatible_installer: false,
11728        };
11729        let candidates = vec![SearchMatch {
11730            source_name: "winget".to_owned(),
11731            source_kind: SourceKind::PreIndexed,
11732            id: "Microsoft.Teams".to_owned(),
11733            name: "Microsoft Teams".to_owned(),
11734            moniker: None,
11735            version: Some("26106.1906.4665.7308".to_owned()),
11736            channel: None,
11737            match_criteria: None,
11738        }];
11739
11740        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
11741    }
11742
11743    #[test]
11744    fn name_fallback_refuses_ambiguous_winners() {
11745        // The user has Git installed (publisher "The Git Development Community"
11746        // — but publisher isn't in the scoring function). Two catalog packages
11747        // both expose name "Git": `Git.Git` and `Microsoft.Git`. Without
11748        // publisher disambiguation they score identically; winget refuses to
11749        // correlate (the install lists with empty Source). pinget must do the
11750        // same to avoid manufacturing an upgrade against the wrong catalog
11751        // package.
11752        let installed = InstalledPackage {
11753            name: "Git".to_owned(),
11754            local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
11755            installed_version: "2.53.0".to_owned(),
11756            publisher: Some("The Git Development Community".to_owned()),
11757            scope: Some("Machine".to_owned()),
11758            installer_category: Some("exe".to_owned()),
11759            install_location: None,
11760            package_family_names: Vec::new(),
11761            product_codes: Vec::new(),
11762            upgrade_codes: Vec::new(),
11763            correlated: None,
11764            installed_version_canonical: false,
11765            correlated_requires_explicit_upgrade: false,
11766            correlated_lacks_compatible_installer: false,
11767        };
11768        let candidates = vec![
11769            SearchMatch {
11770                source_name: "winget".to_owned(),
11771                source_kind: SourceKind::PreIndexed,
11772                id: "Git.Git".to_owned(),
11773                name: "Git".to_owned(),
11774                moniker: None,
11775                version: Some("2.54.0".to_owned()),
11776                channel: None,
11777                match_criteria: None,
11778            },
11779            SearchMatch {
11780                source_name: "winget".to_owned(),
11781                source_kind: SourceKind::PreIndexed,
11782                id: "Microsoft.Git".to_owned(),
11783                name: "Git".to_owned(),
11784                moniker: None,
11785                version: Some("2.53.0.0.7".to_owned()),
11786                channel: None,
11787                match_criteria: None,
11788            },
11789        ];
11790
11791        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
11792    }
11793
11794    #[test]
11795    fn msix_with_resource_string_name_does_not_correlate() {
11796        // Some MSIX entries have unresolved resource-string display names
11797        // (e.g. `ms-resource:appDisplayName`). Those shouldn't latch onto an
11798        // unrelated catalog package via the loose substring rule.
11799        let installed = InstalledPackage {
11800            name: "ms-resource:appDisplayName".to_owned(),
11801            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
11802            installed_version: "1.28.239.0".to_owned(),
11803            publisher: None,
11804            scope: Some("User".to_owned()),
11805            installer_category: Some("msix".to_owned()),
11806            install_location: None,
11807            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
11808            product_codes: Vec::new(),
11809            upgrade_codes: Vec::new(),
11810            correlated: None,
11811            installed_version_canonical: false,
11812            correlated_requires_explicit_upgrade: false,
11813            correlated_lacks_compatible_installer: false,
11814        };
11815        let candidates = vec![SearchMatch {
11816            source_name: "winget".to_owned(),
11817            source_kind: SourceKind::PreIndexed,
11818            id: "Microsoft.AppInstaller".to_owned(),
11819            name: "App Installer".to_owned(),
11820            moniker: None,
11821            version: Some("1.28.240.0".to_owned()),
11822            channel: None,
11823            match_criteria: None,
11824        }];
11825
11826        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
11827    }
11828
11829    #[test]
11830    fn strict_list_correlation_avoids_short_substring_matches() {
11831        let installed = InstalledPackage {
11832            name: "GitHub".to_owned(),
11833            local_id: r"ARP\User\X64\GitHub".to_owned(),
11834            installed_version: "1.0.0".to_owned(),
11835            publisher: None,
11836            scope: Some("User".to_owned()),
11837            installer_category: Some("exe".to_owned()),
11838            install_location: None,
11839            package_family_names: Vec::new(),
11840            product_codes: Vec::new(),
11841            upgrade_codes: Vec::new(),
11842            correlated: None,
11843            installed_version_canonical: false,
11844            correlated_requires_explicit_upgrade: false,
11845            correlated_lacks_compatible_installer: false,
11846        };
11847        let candidates = vec![SearchMatch {
11848            source_name: "winget".to_owned(),
11849            source_kind: SourceKind::PreIndexed,
11850            id: "Git.Git.PreRelease".to_owned(),
11851            name: "Git".to_owned(),
11852            moniker: None,
11853            version: Some("2.54.0".to_owned()),
11854            channel: None,
11855            match_criteria: None,
11856        }];
11857
11858        assert!(correlate_installed_package(&installed, &candidates, false).is_none());
11859    }
11860
11861    #[test]
11862    fn detects_available_upgrade_from_correlated_package() {
11863        let package = InstalledPackage {
11864            name: "AzCopy v10".to_owned(),
11865            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
11866            installed_version: "10.32.2".to_owned(),
11867            publisher: None,
11868            scope: Some("Machine".to_owned()),
11869            installer_category: Some("exe".to_owned()),
11870            install_location: None,
11871            package_family_names: Vec::new(),
11872            product_codes: Vec::new(),
11873            upgrade_codes: Vec::new(),
11874            correlated: Some(SearchMatch {
11875                source_name: "winget".to_owned(),
11876                source_kind: SourceKind::PreIndexed,
11877                id: "Microsoft.Azure.AZCopy.10".to_owned(),
11878                name: "AzCopy v10".to_owned(),
11879                moniker: None,
11880                version: Some("10.32.3".to_owned()),
11881                channel: None,
11882                match_criteria: None,
11883            }),
11884            installed_version_canonical: false,
11885            correlated_requires_explicit_upgrade: false,
11886            correlated_lacks_compatible_installer: false,
11887        };
11888
11889        assert!(installed_package_has_upgrade(&package));
11890    }
11891
11892    #[test]
11893    fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
11894        let package = InstalledPackage {
11895            name: "Example Tool".to_owned(),
11896            local_id: r"ARP\User\X64\ExampleTool".to_owned(),
11897            installed_version: "Unknown".to_owned(),
11898            publisher: None,
11899            scope: Some("User".to_owned()),
11900            installer_category: Some("exe".to_owned()),
11901            install_location: None,
11902            package_family_names: Vec::new(),
11903            product_codes: Vec::new(),
11904            upgrade_codes: Vec::new(),
11905            correlated: Some(SearchMatch {
11906                source_name: "winget".to_owned(),
11907                source_kind: SourceKind::PreIndexed,
11908                id: "Contoso.ExampleTool".to_owned(),
11909                name: "Example Tool".to_owned(),
11910                moniker: None,
11911                version: Some("2.0.0".to_owned()),
11912                channel: None,
11913                match_criteria: None,
11914            }),
11915            installed_version_canonical: false,
11916            correlated_requires_explicit_upgrade: false,
11917            correlated_lacks_compatible_installer: false,
11918        };
11919        let query = ListQuery {
11920            upgrade_only: true,
11921            include_unknown: true,
11922            ..Default::default()
11923        };
11924
11925        assert!(installed_package_matches_upgrade_filter(&package, &query));
11926        assert_eq!(
11927            list_match_from_installed(package).available_version.as_deref(),
11928            Some("2.0.0")
11929        );
11930    }
11931
11932    #[test]
11933    fn create_repair_list_query_includes_installed_selectors() {
11934        let request = RepairRequest {
11935            query: PackageQuery {
11936                query: Some("powertoys".to_owned()),
11937                id: Some("Microsoft.PowerToys".to_owned()),
11938                name: Some("PowerToys".to_owned()),
11939                moniker: Some("powertoys".to_owned()),
11940                source: Some("winget".to_owned()),
11941                exact: true,
11942                version: Some("0.98.1".to_owned()),
11943                install_scope: Some("Machine".to_owned()),
11944                ..PackageQuery::default()
11945            },
11946            manifest_path: None,
11947            product_code: Some("{GUID}".to_owned()),
11948            mode: InstallerMode::Silent,
11949            log_path: Some(PathBuf::from("repair.log")),
11950            accept_package_agreements: true,
11951            force: true,
11952            ignore_security_hash: true,
11953        };
11954
11955        let query = Repository::create_repair_list_query(&request);
11956        assert_eq!(query.query.as_deref(), Some("powertoys"));
11957        assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
11958        assert_eq!(query.name.as_deref(), Some("PowerToys"));
11959        assert_eq!(query.moniker.as_deref(), Some("powertoys"));
11960        assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
11961        assert_eq!(query.version.as_deref(), Some("0.98.1"));
11962        assert_eq!(query.source.as_deref(), Some("winget"));
11963        assert!(query.exact);
11964        assert_eq!(query.install_scope.as_deref(), Some("Machine"));
11965        assert_eq!(query.count, Some(100));
11966    }
11967
11968    #[test]
11969    fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
11970        let request = RepairRequest {
11971            query: PackageQuery {
11972                name: Some("PowerToys".to_owned()),
11973                source: Some("winget".to_owned()),
11974                locale: Some("en-US".to_owned()),
11975                installer_architecture: Some("x64".to_owned()),
11976                install_scope: Some("Machine".to_owned()),
11977                ..PackageQuery::default()
11978            },
11979            manifest_path: None,
11980            product_code: None,
11981            mode: InstallerMode::SilentWithProgress,
11982            log_path: Some(PathBuf::from("repair.log")),
11983            accept_package_agreements: true,
11984            force: false,
11985            ignore_security_hash: true,
11986        };
11987        let installed = ListMatch {
11988            name: "PowerToys".to_owned(),
11989            id: "Microsoft.PowerToys".to_owned(),
11990            local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
11991            installed_version: "0.98.1".to_owned(),
11992            available_version: Some("0.98.2".to_owned()),
11993            source_name: Some("winget".to_owned()),
11994            publisher: None,
11995            scope: Some("Machine".to_owned()),
11996            installer_category: Some("exe".to_owned()),
11997            install_location: None,
11998            package_family_names: Vec::new(),
11999            product_codes: Vec::new(),
12000            upgrade_codes: Vec::new(),
12001        };
12002
12003        let install_request = Repository::create_repair_install_request(&request, Some(&installed));
12004        assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
12005        assert!(install_request.query.query.is_none());
12006        assert!(install_request.query.name.is_none());
12007        assert!(install_request.query.moniker.is_none());
12008        assert_eq!(install_request.query.source.as_deref(), Some("winget"));
12009        assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
12010        assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
12011        assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
12012        assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
12013        assert!(install_request.query.exact);
12014        assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
12015        assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
12016        assert!(install_request.accept_package_agreements);
12017        assert!(install_request.force);
12018        assert!(install_request.ignore_security_hash);
12019    }
12020
12021    #[test]
12022    fn source_scoped_pin_operations_round_trip() {
12023        let app_root = temp_app_root("pins");
12024        let result = (|| -> Result<()> {
12025            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
12026            repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
12027            repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
12028            repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
12029
12030            assert_eq!(repository.list_pins(None)?.len(), 3);
12031            assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
12032            assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
12033
12034            assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
12035            assert_eq!(repository.list_pins(None)?.len(), 2);
12036            assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
12037
12038            repository.reset_pins(Some("test"))?;
12039            let remaining = repository.list_pins(None)?;
12040            assert_eq!(remaining.len(), 1);
12041            assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
12042
12043            repository.reset_pins(None)?;
12044            assert!(repository.list_pins(None)?.is_empty());
12045            Ok(())
12046        })();
12047
12048        let _ = fs::remove_dir_all(&app_root);
12049        result.expect("pin round trip");
12050    }
12051
12052    #[test]
12053    fn pin_operations_work_with_packaged_pin_schema() {
12054        let app_root = temp_app_root("pins_packaged_schema");
12055        let result = (|| -> Result<()> {
12056            let db_path = pins_db_path(&app_root);
12057            if let Some(parent) = db_path.parent() {
12058                fs::create_dir_all(parent)?;
12059            }
12060
12061            let conn = Repository::open_sqlite_connection(db_path.clone())?;
12062            execute_batch_sql(
12063                &conn,
12064                "CREATE TABLE pin (
12065                    package_id TEXT NOT NULL,
12066                    source_id TEXT NOT NULL,
12067                    type INTEGER NOT NULL,
12068                    version TEXT NOT NULL,
12069                    PRIMARY KEY (package_id, source_id)
12070                )",
12071            )?;
12072            conn.cacheflush().context("failed to flush pin database")?;
12073            drop(conn);
12074
12075            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
12076            repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
12077
12078            let pins = repository.list_pins(None)?;
12079            assert_eq!(pins.len(), 1);
12080            assert_eq!(pins[0].package_id, "Contoso.Tool");
12081            assert_eq!(pins[0].version, "1.2.*");
12082            assert_eq!(pins[0].source_id, "winget");
12083            assert_eq!(pins[0].pin_type, PinType::Gating);
12084
12085            let conn = Repository::open_sqlite_connection(db_path)?;
12086            let stored_pin = query_optional_value(
12087                &conn,
12088                "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
12089                vec![
12090                    SqlValue::Text("Contoso.Tool".to_owned()),
12091                    SqlValue::Text("winget".to_owned()),
12092                ],
12093                |row| Ok((row_i64(row, 0)?, row_string(row, 1)?)),
12094            )?
12095            .ok_or_else(|| anyhow!("expected stored pin"))?;
12096            assert_eq!(stored_pin.0, 3);
12097            assert_eq!(stored_pin.1, "1.2.*");
12098
12099            Ok(())
12100        })();
12101
12102        let _ = fs::remove_dir_all(&app_root);
12103        result.expect("packaged pin schema round trip");
12104    }
12105
12106    #[test]
12107    fn version_pin_patterns_match_exact_and_prefix_values() {
12108        assert!(version_matches_pin_pattern("1.2.3", "*"));
12109        assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
12110        assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
12111        assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
12112        assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
12113    }
12114
12115    #[test]
12116    fn source_specific_pins_override_source_agnostic_pins() {
12117        let item = ListMatch {
12118            name: "Contoso Tool".to_owned(),
12119            id: "Contoso.Tool".to_owned(),
12120            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
12121            installed_version: "1.0.0".to_owned(),
12122            available_version: Some("2.0.0".to_owned()),
12123            source_name: Some("winget".to_owned()),
12124            publisher: None,
12125            scope: None,
12126            installer_category: None,
12127            install_location: None,
12128            package_family_names: Vec::new(),
12129            product_codes: Vec::new(),
12130            upgrade_codes: Vec::new(),
12131        };
12132        let pins = vec![
12133            PinRecord {
12134                package_id: "Contoso.Tool".to_owned(),
12135                version: "*".to_owned(),
12136                source_id: String::new(),
12137                pin_type: PinType::Blocking,
12138            },
12139            PinRecord {
12140                package_id: "Contoso.Tool".to_owned(),
12141                version: "2.0.*".to_owned(),
12142                source_id: "winget".to_owned(),
12143                pin_type: PinType::Pinning,
12144            },
12145        ];
12146
12147        assert!(!is_upgrade_blocked_by_pin(&item, &pins));
12148    }
12149
12150    #[test]
12151    fn blocking_pin_blocks_upgrade() {
12152        let item = ListMatch {
12153            name: "Contoso Tool".to_owned(),
12154            id: "Contoso.Tool".to_owned(),
12155            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
12156            installed_version: "1.0.0".to_owned(),
12157            available_version: Some("2.0.0".to_owned()),
12158            source_name: Some("winget".to_owned()),
12159            publisher: None,
12160            scope: None,
12161            installer_category: None,
12162            install_location: None,
12163            package_family_names: Vec::new(),
12164            product_codes: Vec::new(),
12165            upgrade_codes: Vec::new(),
12166        };
12167        let pins = vec![PinRecord {
12168            package_id: "Contoso.Tool".to_owned(),
12169            version: "*".to_owned(),
12170            source_id: "winget".to_owned(),
12171            pin_type: PinType::Blocking,
12172        }];
12173
12174        assert!(is_upgrade_blocked_by_pin(&item, &pins));
12175    }
12176
12177    #[cfg(windows)]
12178    #[test]
12179    fn parses_msix_package_full_name_into_version_and_family() {
12180        let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
12181            .expect("package metadata");
12182
12183        assert_eq!(parsed.version, "0.98.1.0");
12184        assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
12185    }
12186
12187    #[cfg(windows)]
12188    #[test]
12189    fn recognizes_windows_system_paths() {
12190        assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
12191        assert!(!is_windows_system_path(
12192            r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
12193        ));
12194    }
12195
12196    #[test]
12197    fn search_ranking_prefers_exact_name_over_tag_match() {
12198        let query = PackageQuery {
12199            query: Some("PowerToys".to_owned()),
12200            ..Default::default()
12201        };
12202        let exact_name = SearchMatch {
12203            source_name: "winget".to_owned(),
12204            source_kind: SourceKind::PreIndexed,
12205            id: "Microsoft.PowerToys".to_owned(),
12206            name: "PowerToys".to_owned(),
12207            moniker: Some("powertoys".to_owned()),
12208            version: Some("0.98.1".to_owned()),
12209            channel: None,
12210            match_criteria: Some("Moniker: powertoys".to_owned()),
12211        };
12212        let tag_match = SearchMatch {
12213            source_name: "winget".to_owned(),
12214            source_kind: SourceKind::PreIndexed,
12215            id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
12216            name: "QR Codes for Command Palette".to_owned(),
12217            moniker: None,
12218            version: Some("0.4.0.0".to_owned()),
12219            channel: None,
12220            match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
12221        };
12222
12223        assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
12224    }
12225
12226    #[test]
12227    fn select_best_text_match_prefers_exact_tag_value() {
12228        let values = vec![
12229            "microsoft-powertoys".to_owned(),
12230            "powertoys-run".to_owned(),
12231            "powertoys".to_owned(),
12232        ];
12233
12234        assert_eq!(
12235            select_best_text_match(values, "PowerToys", false).as_deref(),
12236            Some("powertoys")
12237        );
12238    }
12239
12240    #[test]
12241    fn search_match_criteria_is_blank_for_name_match() {
12242        let query = PackageQuery {
12243            query: Some("PowerToys".to_owned()),
12244            ..Default::default()
12245        };
12246
12247        let result = infer_match_criteria(
12248            "Microsoft.PowerToys",
12249            "PowerToys",
12250            Some("powertoys"),
12251            &query,
12252            SearchSemantics::Many,
12253            |_| Ok(Some("powertoys".to_owned())),
12254            |_| Ok(None),
12255        )
12256        .expect("match criteria");
12257
12258        assert_eq!(result, None);
12259    }
12260
12261    #[test]
12262    fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
12263        let query = PackageQuery {
12264            query: Some("PowerToys".to_owned()),
12265            ..Default::default()
12266        };
12267        let unknown_version = SearchMatch {
12268            source_name: "msstore".to_owned(),
12269            source_kind: SourceKind::Rest,
12270            id: "XP89DCGQ3K6VLD".to_owned(),
12271            name: "Microsoft PowerToys".to_owned(),
12272            moniker: None,
12273            version: Some("Unknown".to_owned()),
12274            channel: None,
12275            match_criteria: None,
12276        };
12277        let tag_match = SearchMatch {
12278            source_name: "winget".to_owned(),
12279            source_kind: SourceKind::PreIndexed,
12280            id: "Riri.QRCodeforCmdPal".to_owned(),
12281            name: "QR Code for CmdPal".to_owned(),
12282            moniker: None,
12283            version: Some("0.0.1.0".to_owned()),
12284            channel: None,
12285            match_criteria: Some("Tag: powertoys".to_owned()),
12286        };
12287
12288        assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
12289    }
12290
12291    #[test]
12292    fn search_source_fetch_results_ignore_small_display_count() {
12293        let query = PackageQuery {
12294            count: Some(5),
12295            ..Default::default()
12296        };
12297
12298        assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
12299        assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
12300    }
12301
12302    #[test]
12303    fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
12304        let query = PackageQuery {
12305            query: Some("PowerToys".to_owned()),
12306            ..Default::default()
12307        };
12308        let prefix_name = SearchMatch {
12309            source_name: "winget".to_owned(),
12310            source_kind: SourceKind::PreIndexed,
12311            id: "advaith.CurrencyConverterPowerToys".to_owned(),
12312            name: "PowerToys-Run-Currency-Converter".to_owned(),
12313            moniker: None,
12314            version: Some("1.5.4".to_owned()),
12315            channel: None,
12316            match_criteria: None,
12317        };
12318        let exact_tag = SearchMatch {
12319            source_name: "winget".to_owned(),
12320            source_kind: SourceKind::PreIndexed,
12321            id: "Riri.QRCodeforCmdPal".to_owned(),
12322            name: "QR Code for CmdPal".to_owned(),
12323            moniker: None,
12324            version: Some("0.0.1.0".to_owned()),
12325            channel: None,
12326            match_criteria: Some("Tag: powertoys".to_owned()),
12327        };
12328
12329        assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
12330    }
12331
12332    #[test]
12333    fn list_sort_prefers_main_package_then_sparse_app() {
12334        let main = InstalledPackage {
12335            name: "PowerToys (Preview) x64".to_owned(),
12336            local_id: r"ARP\User\X64\PowerToys".to_owned(),
12337            installed_version: "0.98.1".to_owned(),
12338            publisher: None,
12339            scope: Some("User".to_owned()),
12340            installer_category: Some("exe".to_owned()),
12341            install_location: None,
12342            package_family_names: Vec::new(),
12343            product_codes: Vec::new(),
12344            upgrade_codes: Vec::new(),
12345            correlated: None,
12346            installed_version_canonical: false,
12347            correlated_requires_explicit_upgrade: false,
12348            correlated_lacks_compatible_installer: false,
12349        };
12350        let sparse = InstalledPackage {
12351            name: "PowerToys.SparseApp".to_owned(),
12352            local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
12353            installed_version: "0.98.1.0".to_owned(),
12354            publisher: None,
12355            scope: Some("User".to_owned()),
12356            installer_category: Some("msix".to_owned()),
12357            install_location: None,
12358            package_family_names: Vec::new(),
12359            product_codes: Vec::new(),
12360            upgrade_codes: Vec::new(),
12361            correlated: None,
12362            installed_version_canonical: false,
12363            correlated_requires_explicit_upgrade: false,
12364            correlated_lacks_compatible_installer: false,
12365        };
12366        let extension = InstalledPackage {
12367            name: "PowerToys FileLocksmith Context Menu".to_owned(),
12368            local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
12369            installed_version: "0.98.1.0".to_owned(),
12370            publisher: None,
12371            scope: Some("User".to_owned()),
12372            installer_category: Some("msix".to_owned()),
12373            install_location: None,
12374            package_family_names: Vec::new(),
12375            product_codes: Vec::new(),
12376            upgrade_codes: Vec::new(),
12377            correlated: None,
12378            installed_version_canonical: false,
12379            correlated_requires_explicit_upgrade: false,
12380            correlated_lacks_compatible_installer: false,
12381        };
12382
12383        assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
12384        assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
12385    }
12386
12387    #[test]
12388    #[cfg(windows)]
12389    fn arp_subkey_name_extracts_registry_subkey() {
12390        assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
12391        assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
12392    }
12393
12394    #[test]
12395    #[cfg(windows)]
12396    fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
12397        let installed = ListMatch {
12398            name: "ShareX".to_owned(),
12399            id: "ShareX.ShareX".to_owned(),
12400            local_id: r"ARP\Machine\X64\ShareX".to_owned(),
12401            installed_version: "19.0.2".to_owned(),
12402            available_version: None,
12403            source_name: Some("winget".to_owned()),
12404            publisher: Some("ShareX Team".to_owned()),
12405            scope: Some("Machine".to_owned()),
12406            installer_category: Some("exe".to_owned()),
12407            install_location: Some(r"C:\Program Files\ShareX".to_owned()),
12408            package_family_names: Vec::new(),
12409            product_codes: Vec::new(),
12410            upgrade_codes: Vec::new(),
12411        };
12412
12413        assert!(registry_entry_matches_installed_package(
12414            "ShareX", "ShareX", None, &installed
12415        ));
12416        assert!(!registry_entry_matches_installed_package(
12417            "ShareX.ShareX",
12418            "ShareX.ShareX",
12419            None,
12420            &installed
12421        ));
12422    }
12423
12424    #[test]
12425    #[cfg(windows)]
12426    fn build_uninstall_command_only_appends_silent_flag_when_needed() {
12427        assert_eq!(
12428            r#""C:\Program Files\ShareX\unins000.exe" /S"#,
12429            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
12430        );
12431        assert_eq!(
12432            r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
12433            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
12434        );
12435        assert_eq!(
12436            r#""C:\Program Files\ShareX\unins000.exe""#,
12437            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
12438        );
12439        assert_eq!(
12440            "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
12441            build_uninstall_command(
12442                "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
12443                true,
12444                false,
12445            )
12446        );
12447    }
12448
12449    #[test]
12450    #[cfg(windows)]
12451    fn build_winget_uninstall_arguments_prefers_correlated_identity() {
12452        let installed = ListMatch {
12453            name: "lazygit".to_owned(),
12454            id: "JesseDuffield.lazygit".to_owned(),
12455            local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
12456            installed_version: "0.61.1".to_owned(),
12457            available_version: None,
12458            source_name: Some("winget".to_owned()),
12459            publisher: Some("Jesse Duffield".to_owned()),
12460            scope: Some("User".to_owned()),
12461            installer_category: Some("exe".to_owned()),
12462            install_location: Some(
12463                r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
12464            ),
12465            package_family_names: Vec::new(),
12466            product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
12467            upgrade_codes: Vec::new(),
12468        };
12469        let mut request = UninstallRequest::new(PackageQuery {
12470            id: Some("JesseDuffield.lazygit".to_owned()),
12471            install_scope: Some("user".to_owned()),
12472            ..PackageQuery::default()
12473        });
12474        request.mode = InstallerMode::Silent;
12475
12476        assert_eq!(
12477            Some(vec![
12478                "uninstall".to_owned(),
12479                "--id".to_owned(),
12480                "JesseDuffield.lazygit".to_owned(),
12481                "--exact".to_owned(),
12482                "--disable-interactivity".to_owned(),
12483                "--source".to_owned(),
12484                "winget".to_owned(),
12485                "--scope".to_owned(),
12486                "user".to_owned(),
12487                "--silent".to_owned(),
12488            ]),
12489            build_winget_uninstall_arguments(&installed, &request)
12490        );
12491
12492        assert_eq!(
12493            Some(vec![
12494                "uninstall".to_owned(),
12495                "--id".to_owned(),
12496                "JesseDuffield.lazygit".to_owned(),
12497                "--exact".to_owned(),
12498                "--disable-interactivity".to_owned(),
12499                "--source".to_owned(),
12500                "winget".to_owned(),
12501                "--silent".to_owned(),
12502            ]),
12503            build_winget_uninstall_arguments_with_scope(&installed, &request, false)
12504        );
12505    }
12506
12507    fn version_entry(version: &str, arp_min: Option<&str>, arp_max: Option<&str>) -> PackageVersionDataEntry {
12508        PackageVersionDataEntry {
12509            version: version.to_owned(),
12510            manifest_relative_path: format!("manifests/x/{version}"),
12511            manifest_hash: "00".repeat(32),
12512            arp_min_version: arp_min.map(str::to_owned),
12513            arp_max_version: arp_max.map(str::to_owned),
12514        }
12515    }
12516
12517    fn v1_version(version: &str, channel: &str) -> V1VersionRow {
12518        V1VersionRow {
12519            version: version.to_owned(),
12520            channel: channel.to_owned(),
12521            pathpart_id: 0,
12522            manifest_hash: None,
12523        }
12524    }
12525
12526    #[derive(Clone)]
12527    struct TestHttpResponse {
12528        status: u16,
12529        body: Vec<u8>,
12530        source_version: Option<String>,
12531    }
12532
12533    struct TestHttpServer {
12534        addr: SocketAddr,
12535        routes: Arc<Mutex<HashMap<String, TestHttpResponse>>>,
12536        counts: Arc<Mutex<HashMap<String, usize>>>,
12537        running: Arc<AtomicBool>,
12538        handle: Option<JoinHandle<()>>,
12539    }
12540
12541    impl TestHttpServer {
12542        fn start() -> Self {
12543            let listener = TcpListener::bind("127.0.0.1:0").expect("bind test HTTP server");
12544            let addr = listener.local_addr().expect("test HTTP server address");
12545            let routes = Arc::new(Mutex::new(HashMap::new()));
12546            let counts = Arc::new(Mutex::new(HashMap::new()));
12547            let running = Arc::new(AtomicBool::new(true));
12548            let thread_routes = Arc::clone(&routes);
12549            let thread_counts = Arc::clone(&counts);
12550            let thread_running = Arc::clone(&running);
12551            let handle = thread::spawn(move || {
12552                while thread_running.load(AtomicOrdering::SeqCst) {
12553                    let Ok((stream, _)) = listener.accept() else {
12554                        continue;
12555                    };
12556                    handle_test_http_request(stream, &thread_routes, &thread_counts);
12557                }
12558            });
12559
12560            Self {
12561                addr,
12562                routes,
12563                counts,
12564                running,
12565                handle: Some(handle),
12566            }
12567        }
12568
12569        fn url(&self) -> String {
12570            format!("http://{}", self.addr)
12571        }
12572
12573        fn set_bytes(&self, path: &str, body: Vec<u8>) {
12574            self.set_response(
12575                path,
12576                TestHttpResponse {
12577                    status: 200,
12578                    body,
12579                    source_version: None,
12580                },
12581            );
12582        }
12583
12584        fn set_source_package(&self, body: Vec<u8>, source_version: &str) {
12585            self.set_response(
12586                "/source.msix",
12587                TestHttpResponse {
12588                    status: 200,
12589                    body,
12590                    source_version: Some(source_version.to_owned()),
12591                },
12592            );
12593        }
12594
12595        fn set_status(&self, path: &str, status: u16) {
12596            self.set_response(
12597                path,
12598                TestHttpResponse {
12599                    status,
12600                    body: Vec::new(),
12601                    source_version: None,
12602                },
12603            );
12604        }
12605
12606        fn set_response(&self, path: &str, response: TestHttpResponse) {
12607            self.routes
12608                .lock()
12609                .expect("test HTTP routes")
12610                .insert(path.to_owned(), response);
12611        }
12612
12613        fn request_count(&self, path: &str) -> usize {
12614            self.counts
12615                .lock()
12616                .expect("test HTTP counts")
12617                .get(path)
12618                .copied()
12619                .unwrap_or_default()
12620        }
12621    }
12622
12623    impl Drop for TestHttpServer {
12624        fn drop(&mut self) {
12625            self.running.store(false, AtomicOrdering::SeqCst);
12626            let _ = TcpStream::connect(self.addr);
12627            if let Some(handle) = self.handle.take() {
12628                let _ = handle.join();
12629            }
12630        }
12631    }
12632
12633    fn handle_test_http_request(
12634        mut stream: TcpStream,
12635        routes: &Arc<Mutex<HashMap<String, TestHttpResponse>>>,
12636        counts: &Arc<Mutex<HashMap<String, usize>>>,
12637    ) {
12638        let mut buffer = [0u8; 2048];
12639        let read = stream.read(&mut buffer).unwrap_or(0);
12640        let request = String::from_utf8_lossy(&buffer[..read]);
12641        let path = request
12642            .lines()
12643            .next()
12644            .and_then(|line| line.split_whitespace().nth(1))
12645            .unwrap_or("/");
12646        *counts
12647            .lock()
12648            .expect("test HTTP counts")
12649            .entry(path.to_owned())
12650            .or_default() += 1;
12651        let response = routes.lock().expect("test HTTP routes").get(path).cloned();
12652        let response = response.unwrap_or(TestHttpResponse {
12653            status: 404,
12654            body: Vec::new(),
12655            source_version: None,
12656        });
12657        let reason = if response.status == 200 { "OK" } else { "Not Found" };
12658        let mut headers = format!(
12659            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
12660            response.status,
12661            reason,
12662            response.body.len()
12663        );
12664        if let Some(source_version) = response.source_version {
12665            headers.push_str(&format!("x-ms-meta-sourceversion: {source_version}\r\n"));
12666        }
12667        headers.push_str("\r\n");
12668        stream.write_all(headers.as_bytes()).expect("write test HTTP headers");
12669        stream.write_all(&response.body).expect("write test HTTP body");
12670    }
12671
12672    fn test_source_record(source_url: String) -> SourceRecord {
12673        SourceRecord {
12674            name: "test-winget".to_owned(),
12675            kind: SourceKind::PreIndexed,
12676            arg: source_url,
12677            identifier: "Test.Source".to_owned(),
12678            trust_level: "Trusted".to_owned(),
12679            explicit: false,
12680            priority: 0,
12681            last_update: Some(Utc::now()),
12682            source_version: None,
12683        }
12684    }
12685
12686    fn open_test_preindexed_repository(
12687        app_root: &Path,
12688        source: &SourceRecord,
12689        interval: Option<Duration>,
12690    ) -> Repository {
12691        fs::create_dir_all(app_root).expect("create app root");
12692        save_store(
12693            app_root,
12694            &SourceStore {
12695                sources: vec![source.clone()],
12696            },
12697        )
12698        .expect("save source store");
12699        Repository::open_with_options(
12700            RepositoryOptions::new(app_root.to_path_buf()).with_pre_indexed_source_auto_update_interval(interval),
12701        )
12702        .expect("open repository")
12703    }
12704
12705    fn write_test_preindexed_index(app_root: &Path, source: &SourceRecord, versions: &[&str]) {
12706        let index_path = preindexed_index_path(app_root, source);
12707        if let Some(parent) = index_path.parent() {
12708            fs::create_dir_all(parent).expect("create source state directory");
12709        }
12710        fs::write(index_path, make_v1_index_bytes(versions)).expect("write preindexed index");
12711    }
12712
12713    #[test]
12714    fn replace_file_failure_keeps_existing_target() {
12715        let root = temp_app_root("replace_file_failure");
12716        fs::create_dir_all(&root).expect("create replace root");
12717        let target = root.join("index.db");
12718        let missing_source = root.join("missing.tmp");
12719        fs::write(&target, b"old-index").expect("write existing target");
12720
12721        let error = replace_file(&missing_source, &target).expect_err("missing replacement should fail");
12722
12723        assert!(format!("{error:#}").contains("failed to replace"));
12724        assert_eq!(fs::read(&target).expect("existing target remains"), b"old-index");
12725        let _ = fs::remove_dir_all(root);
12726    }
12727
12728    #[cfg(windows)]
12729    #[test]
12730    fn replace_file_succeeds_while_target_is_open() {
12731        use std::os::windows::fs::OpenOptionsExt;
12732        // FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE — the sharing
12733        // mode SQLite/Turso uses. An atomic MOVEFILE_REPLACE_EXISTING is denied
12734        // while this handle is open, so the stage-aside fallback must take over.
12735        const SHARE_ALL: u32 = 0x0000_0001 | 0x0000_0002 | 0x0000_0004;
12736
12737        let root = temp_app_root("replace_file_open_target");
12738        fs::create_dir_all(&root).expect("create replace root");
12739        let target = root.join("index.db");
12740        let source = root.join("index.new.tmp");
12741        fs::write(&target, b"old-index").expect("write existing target");
12742        fs::write(&source, b"new-index").expect("write replacement");
12743
12744        let held = fs::OpenOptions::new()
12745            .read(true)
12746            .share_mode(SHARE_ALL)
12747            .open(&target)
12748            .expect("open target with share-delete");
12749
12750        replace_file(&source, &target).expect("stage-aside replace should succeed");
12751
12752        assert_eq!(fs::read(&target).expect("new index in place"), b"new-index");
12753        drop(held);
12754        let _ = fs::remove_dir_all(root);
12755    }
12756
12757    fn make_source_package(versions: &[&str]) -> Vec<u8> {
12758        let cursor = Cursor::new(Vec::new());
12759        let mut archive = zip::ZipWriter::new(cursor);
12760        let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
12761        archive
12762            .start_file("Public/index.db", options)
12763            .expect("start index entry");
12764        archive
12765            .write_all(&make_v1_index_bytes(versions))
12766            .expect("write index entry");
12767        archive.finish().expect("finish source package").into_inner()
12768    }
12769
12770    fn make_v1_index_bytes(versions: &[&str]) -> Vec<u8> {
12771        let root = temp_app_root("preindexed_index_bytes");
12772        fs::create_dir_all(&root).expect("create index temp root");
12773        let db_path = root.join("index.db");
12774        let connection = Repository::open_sqlite_connection(db_path.clone()).expect("open index db");
12775        let mut sql = String::from(
12776            "CREATE TABLE ids (id TEXT);
12777             CREATE TABLE names (name TEXT);
12778             CREATE TABLE monikers (moniker TEXT);
12779             CREATE TABLE versions (version TEXT);
12780             CREATE TABLE channels (channel TEXT);
12781             CREATE TABLE pathparts (parent INT64, pathpart TEXT);
12782             CREATE TABLE manifest (
12783                 id INT64,
12784                 name INT64,
12785                 moniker INT64,
12786                 version INT64,
12787                 channel INT64,
12788                 pathpart INT64
12789             );
12790             INSERT INTO ids(rowid, id) VALUES (1, 'Test.Package');
12791             INSERT INTO names(rowid, name) VALUES (1, 'Test Package');
12792             INSERT INTO channels(rowid, channel) VALUES (1, '');
12793             INSERT INTO pathparts(rowid, parent, pathpart) VALUES (1, NULL, 'manifests');
12794             INSERT INTO pathparts(rowid, parent, pathpart) VALUES (2, 1, 'Test.Package.yaml');",
12795        );
12796        for (index, version) in versions.iter().enumerate() {
12797            let rowid = index + 1;
12798            sql.push_str(&format!(
12799                "INSERT INTO versions(rowid, version) VALUES ({rowid}, '{version}');
12800                 INSERT INTO manifest(id, name, moniker, version, channel, pathpart) VALUES (1, 1, NULL, {rowid}, 1, 2);"
12801            ));
12802        }
12803        execute_batch_sql(&connection, &sql).expect("seed preindexed V1 database");
12804        let _ = query_rows(&connection, "PRAGMA wal_checkpoint(TRUNCATE)", Vec::new(), |_| Ok(()))
12805            .expect("checkpoint preindexed V1 database");
12806        connection.cacheflush().expect("flush preindexed V1 database");
12807        drop(connection);
12808        let bytes = fs::read(&db_path).expect("read index bytes");
12809        let _ = fs::remove_dir_all(root);
12810        bytes
12811    }
12812
12813    fn test_manifest_yaml(version: &str) -> Vec<u8> {
12814        format!(
12815            "PackageIdentifier: Test.Package\nPackageVersion: {version}\nPackageName: Test Package\nPublisher: Test\n"
12816        )
12817        .into_bytes()
12818    }
12819
12820    fn show_test_package(version: Option<&str>) -> PackageQuery {
12821        PackageQuery {
12822            id: Some("Test.Package".to_owned()),
12823            source: Some("test-winget".to_owned()),
12824            exact: true,
12825            version: version.map(str::to_owned),
12826            ..PackageQuery::default()
12827        }
12828    }
12829
12830    #[test]
12831    fn preindexed_explicit_missing_version_refreshes_once_and_returns_requested_version() {
12832        let app_root = temp_app_root("preindexed_explicit_refresh");
12833        let server = TestHttpServer::start();
12834        server.set_status("/source2.msix", 404);
12835        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "2");
12836        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
12837        let source = test_source_record(server.url());
12838        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12839        let mut repository = open_test_preindexed_repository(&app_root, &source, None);
12840
12841        let result = repository
12842            .show(&show_test_package(Some("2.0.0")))
12843            .expect("show after refresh");
12844
12845        assert_eq!(result.manifest.version, "2.0.0");
12846        assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("2"));
12847        let _ = fs::remove_dir_all(app_root);
12848    }
12849
12850    #[test]
12851    fn preindexed_explicit_missing_version_fails_after_refresh_instead_of_returning_latest() {
12852        let app_root = temp_app_root("preindexed_explicit_still_missing");
12853        let server = TestHttpServer::start();
12854        server.set_status("/source2.msix", 404);
12855        server.set_source_package(make_source_package(&["1.0.0"]), "same");
12856        let source = test_source_record(server.url());
12857        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12858        let mut repository = open_test_preindexed_repository(&app_root, &source, None);
12859
12860        let error = repository
12861            .show(&show_test_package(Some("2.0.0")))
12862            .expect_err("missing explicit version must fail");
12863
12864        assert!(format!("{error:#}").contains("2.0.0"));
12865        let _ = fs::remove_dir_all(app_root);
12866    }
12867
12868    #[test]
12869    fn preindexed_stale_index_refreshes_for_latest_requests() {
12870        let app_root = temp_app_root("preindexed_ttl_refresh");
12871        let server = TestHttpServer::start();
12872        server.set_status("/source2.msix", 404);
12873        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
12874        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
12875        let mut source = test_source_record(server.url());
12876        source.last_update = Some(Utc::now() - Duration::days(2));
12877        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12878        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
12879
12880        let result = repository
12881            .show(&show_test_package(None))
12882            .expect("show latest after TTL refresh");
12883
12884        assert_eq!(result.manifest.version, "2.0.0");
12885        assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("fresh"));
12886        let _ = fs::remove_dir_all(app_root);
12887    }
12888
12889    #[test]
12890    fn preindexed_fresh_index_mtime_skips_refresh_when_metadata_is_stale() {
12891        let app_root = temp_app_root("preindexed_fresh_mtime");
12892        let server = TestHttpServer::start();
12893        server.set_status("/source2.msix", 404);
12894        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
12895        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
12896        let mut source = test_source_record(server.url());
12897        source.last_update = Some(Utc::now() - Duration::days(2));
12898        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12899        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
12900
12901        let result = repository
12902            .show(&show_test_package(None))
12903            .expect("show latest from fresh index mtime");
12904
12905        assert_eq!(result.manifest.version, "1.0.0");
12906        assert_eq!(server.request_count("/source.msix"), 0);
12907        let _ = fs::remove_dir_all(app_root);
12908    }
12909
12910    #[test]
12911    fn preindexed_stale_refresh_failure_falls_back_for_latest_requests() {
12912        let app_root = temp_app_root("preindexed_ttl_fallback");
12913        let server = TestHttpServer::start();
12914        server.set_status("/source2.msix", 404);
12915        server.set_status("/source.msix", 404);
12916        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
12917        let mut source = test_source_record(server.url());
12918        source.last_update = Some(Utc::now() - Duration::days(2));
12919        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12920        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
12921
12922        let result = repository
12923            .show(&show_test_package(None))
12924            .expect("show latest from stale cache after refresh failure");
12925
12926        assert_eq!(result.manifest.version, "1.0.0");
12927        let _ = fs::remove_dir_all(app_root);
12928    }
12929
12930    #[test]
12931    fn preindexed_explicit_missing_version_does_not_refresh_twice_after_stale_refresh() {
12932        let app_root = temp_app_root("preindexed_no_double_refresh");
12933        let server = TestHttpServer::start();
12934        server.set_status("/source2.msix", 404);
12935        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
12936        let mut source = test_source_record(server.url());
12937        source.last_update = Some(Utc::now() - Duration::days(2));
12938        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12939        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
12940
12941        let error = repository
12942            .show(&show_test_package(Some("3.0.0")))
12943            .expect_err("still-missing explicit version must fail after one stale refresh");
12944
12945        assert!(format!("{error:#}").contains("3.0.0"));
12946        assert_eq!(server.request_count("/source.msix"), 1);
12947        let _ = fs::remove_dir_all(app_root);
12948    }
12949
12950    #[test]
12951    fn select_v2_version_without_explicit_filters_returns_latest() {
12952        let versions = vec![
12953            version_entry("1.0.0", None, None),
12954            version_entry("1.2.0", None, None),
12955            version_entry("1.1.0", None, None),
12956        ];
12957
12958        let selected = select_v2_version(&versions, None, None, "Test.Package", "test").expect("latest version");
12959
12960        assert_eq!(selected.version, "1.2.0");
12961    }
12962
12963    #[test]
12964    fn select_v2_version_with_missing_requested_version_throws() {
12965        let versions = vec![version_entry("1.2.0", None, None), version_entry("1.1.0", None, None)];
12966
12967        let error = select_v2_version(&versions, Some("9.9.9"), None, "Test.Package", "test")
12968            .expect_err("missing requested version");
12969
12970        assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
12971        assert!(error.to_string().contains("9.9.9"));
12972    }
12973
12974    #[test]
12975    fn select_v2_version_with_requested_channel_throws_instead_of_ignoring_channel() {
12976        let versions = vec![version_entry("1.2.0", None, None)];
12977
12978        let error = select_v2_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
12979            .expect_err("unsupported channel request");
12980
12981        assert!(error.to_string().contains("beta"));
12982        assert!(error.to_string().contains("channel metadata"));
12983    }
12984
12985    #[test]
12986    fn select_v1_version_channel_only_returns_latest_matching_channel() {
12987        let versions = vec![
12988            v1_version("1.2.0", "stable"),
12989            v1_version("1.3.0", "stable"),
12990            v1_version("2.0.0", "preview"),
12991        ];
12992
12993        let selected =
12994            select_v1_version(&versions, None, Some("stable"), "Test.Package", "test").expect("latest stable version");
12995
12996        assert_eq!(selected.version, "1.3.0");
12997    }
12998
12999    #[test]
13000    fn select_rest_version_channel_mismatch_throws() {
13001        let versions = vec![
13002            VersionKey {
13003                version: "1.2.0".to_owned(),
13004                channel: "stable".to_owned(),
13005            },
13006            VersionKey {
13007                version: "1.1.0".to_owned(),
13008                channel: "beta".to_owned(),
13009            },
13010        ];
13011
13012        let error = select_rest_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
13013            .expect_err("missing version and channel pair");
13014
13015        assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
13016    }
13017
13018    #[test]
13019    fn map_arp_version_returns_catalog_version_inside_range() {
13020        // Real winget data: .NET SDK 10.0.108 declares its ARP DisplayVersion is
13021        // `10.1.826.23019`. compare_version on that against the catalog Version
13022        // `10.0.108` claims installed > available, so without this mapping the
13023        // upgrade was silently dropped.
13024        let entries = vec![
13025            version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
13026            version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
13027            version_entry("10.0.107", Some("10.1.726.21808"), Some("10.1.726.21808")),
13028        ];
13029
13030        assert_eq!(
13031            map_arp_version_to_catalog(&entries, "10.1.826.23019").as_deref(),
13032            Some("10.0.108")
13033        );
13034    }
13035
13036    #[test]
13037    fn map_arp_version_returns_none_when_no_range_matches() {
13038        // VS-installed .NET SDK reports ARP DisplayVersion `40.10.18029` which
13039        // doesn't sit inside any aMiV..aMaV bucket. Caller must keep the
13040        // original installed version (and accept that no upgrade can be
13041        // computed) — same as winget's behavior.
13042        let entries = vec![
13043            version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
13044            version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
13045        ];
13046
13047        assert!(map_arp_version_to_catalog(&entries, "40.10.18029").is_none());
13048    }
13049
13050    #[test]
13051    fn map_arp_version_skips_entries_missing_arp_bounds() {
13052        // Older catalog packages predate AppsAndFeaturesEntries and have no
13053        // aMiV/aMaV. Those entries must be ignored — not silently treated as
13054        // an unbounded range.
13055        let entries = vec![
13056            version_entry("9.0.300", None, None),
13057            version_entry("9.0.117", Some("9.1.1726.23010"), Some("9.1.1726.23010")),
13058        ];
13059
13060        assert_eq!(
13061            map_arp_version_to_catalog(&entries, "9.1.1726.23010").as_deref(),
13062            Some("9.0.117")
13063        );
13064        assert!(map_arp_version_to_catalog(&entries, "1.0.0").is_none());
13065    }
13066
13067    #[test]
13068    fn map_arp_version_handles_inclusive_range_endpoints() {
13069        // aMiV/aMaV are inclusive bounds — a version equal to either endpoint
13070        // must still map.
13071        let entries = vec![version_entry("3.14.5", Some("3.14.5.0"), Some("3.14.5.999"))];
13072
13073        assert_eq!(
13074            map_arp_version_to_catalog(&entries, "3.14.5.0").as_deref(),
13075            Some("3.14.5")
13076        );
13077        assert_eq!(
13078            map_arp_version_to_catalog(&entries, "3.14.5.999").as_deref(),
13079            Some("3.14.5")
13080        );
13081        assert_eq!(
13082            map_arp_version_to_catalog(&entries, "3.14.5.500").as_deref(),
13083            Some("3.14.5")
13084        );
13085    }
13086
13087    #[test]
13088    fn map_arp_version_ignores_unknown_marker() {
13089        // collect_installed_packages writes the literal "Unknown" when ARP has
13090        // no DisplayVersion. Trying to map that would falsely match the first
13091        // entry whose aMiV..aMaV string-compares to include "Unknown".
13092        let entries = vec![version_entry("1.0.0", Some("1.0.0"), Some("1.0.0"))];
13093
13094        assert!(map_arp_version_to_catalog(&entries, "Unknown").is_none());
13095        assert!(map_arp_version_to_catalog(&entries, "").is_none());
13096    }
13097
13098    #[test]
13099    fn latest_arp_anchored_skips_internal_versions() {
13100        // Microsoft.WindowsAppRuntime.1.8 publishes both an internal build
13101        // version (`8000.836.2153.0`, no ARP bounds) and user-facing versions
13102        // (`1.8.6`, `1.8.5`, …). The internal row shouldn't win — comparing
13103        // it against an installed canonical of `1.8.6` would falsely report
13104        // an upgrade. Returns the highest anchored Version instead.
13105        let entries = vec![
13106            version_entry("8000.836.2153.0", None, None),
13107            version_entry("1.8.6", Some("8000.806.2252.0"), Some("8000.806.2252.0")),
13108            version_entry("1.8.5", Some("8000.770.947.0"), Some("8000.770.947.0")),
13109            version_entry("1.8.0", Some("8000.616.304.0"), Some("8000.616.304.0")),
13110        ];
13111
13112        assert_eq!(latest_arp_anchored_version(&entries).as_deref(), Some("1.8.6"));
13113    }
13114
13115    #[test]
13116    fn latest_arp_anchored_returns_none_without_bounds() {
13117        // App Installer's versionData has no aMiV/aMaV — every version is a
13118        // user-facing MSIX Version. Caller must fall back to
13119        // `packages.latest_version` in this case.
13120        let entries = vec![
13121            version_entry("1.28.240.0", None, None),
13122            version_entry("1.27.470.0", None, None),
13123        ];
13124
13125        assert!(latest_arp_anchored_version(&entries).is_none());
13126    }
13127
13128    #[test]
13129    fn list_match_from_installed_prefers_correlated_id_and_source_name() {
13130        let package = InstalledPackage {
13131            name: "AzCopy v10".to_owned(),
13132            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
13133            installed_version: "10.32.2".to_owned(),
13134            publisher: Some("Microsoft".to_owned()),
13135            scope: Some("Machine".to_owned()),
13136            installer_category: Some("exe".to_owned()),
13137            install_location: None,
13138            package_family_names: Vec::new(),
13139            product_codes: Vec::new(),
13140            upgrade_codes: Vec::new(),
13141            correlated: Some(SearchMatch {
13142                source_name: "winget".to_owned(),
13143                source_kind: SourceKind::PreIndexed,
13144                id: "Microsoft.Azure.AZCopy.10".to_owned(),
13145                name: "AzCopy v10".to_owned(),
13146                moniker: None,
13147                version: Some("10.32.3".to_owned()),
13148                channel: None,
13149                match_criteria: None,
13150            }),
13151            installed_version_canonical: false,
13152            correlated_requires_explicit_upgrade: false,
13153            correlated_lacks_compatible_installer: false,
13154        };
13155
13156        let item = list_match_from_installed(package);
13157
13158        assert_eq!(item.id, "Microsoft.Azure.AZCopy.10");
13159        assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy");
13160        assert_eq!(item.source_name.as_deref(), Some("winget"));
13161        assert_eq!(item.available_version.as_deref(), Some("10.32.3"));
13162    }
13163
13164    #[test]
13165    fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() {
13166        let package = InstalledPackage {
13167            name: "Atlassian CLI".to_owned(),
13168            local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
13169            installed_version: "1.3.18-stable".to_owned(),
13170            publisher: Some("Atlassian".to_owned()),
13171            scope: Some("User".to_owned()),
13172            installer_category: Some("exe".to_owned()),
13173            install_location: None,
13174            package_family_names: Vec::new(),
13175            product_codes: Vec::new(),
13176            upgrade_codes: Vec::new(),
13177            correlated: Some(SearchMatch {
13178                source_name: "winget".to_owned(),
13179                source_kind: SourceKind::PreIndexed,
13180                id: "Atlassian.AtlassianCLI".to_owned(),
13181                name: "Atlassian CLI".to_owned(),
13182                moniker: None,
13183                version: Some("1.3.18-stable".to_owned()),
13184                channel: None,
13185                match_criteria: Some("ProductCode".to_owned()),
13186            }),
13187            installed_version_canonical: false,
13188            correlated_requires_explicit_upgrade: false,
13189            correlated_lacks_compatible_installer: false,
13190        };
13191
13192        let item = list_match_from_installed(package);
13193
13194        assert_eq!(item.id, "Atlassian.AtlassianCLI");
13195        assert_eq!(
13196            item.local_id,
13197            r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe"
13198        );
13199        assert_ne!(item.id, item.local_id);
13200        assert_eq!(item.source_name.as_deref(), Some("winget"));
13201    }
13202
13203    #[test]
13204    fn list_response_serialization_keeps_pascal_case_fields() {
13205        let response = ListResponse {
13206            matches: vec![ListMatch {
13207                name: "PowerToys".to_owned(),
13208                id: "Microsoft.PowerToys".to_owned(),
13209                local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
13210                installed_version: "0.98.1".to_owned(),
13211                available_version: Some("0.99.0".to_owned()),
13212                source_name: Some("winget".to_owned()),
13213                publisher: Some("Microsoft".to_owned()),
13214                scope: Some("Machine".to_owned()),
13215                installer_category: Some("msi".to_owned()),
13216                install_location: None,
13217                package_family_names: Vec::new(),
13218                product_codes: Vec::new(),
13219                upgrade_codes: Vec::new(),
13220            }],
13221            warnings: Vec::new(),
13222            truncated: false,
13223        };
13224
13225        let value = serde_json::to_value(&response).expect("serialize list response");
13226        let match_value = &value["Matches"][0];
13227
13228        assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys");
13229        assert_eq!(match_value["InstalledVersion"], "0.98.1");
13230        assert_eq!(match_value["AvailableVersion"], "0.99.0");
13231        assert_eq!(match_value["SourceName"], "winget");
13232        assert!(match_value.get("local_id").is_none());
13233        assert!(match_value.get("installed_version").is_none());
13234        assert!(match_value.get("available_version").is_none());
13235        assert!(match_value.get("source_name").is_none());
13236    }
13237
13238    #[test]
13239    fn search_response_serialization_preserves_minimum_pascal_case_shape() {
13240        let response = SearchResponse {
13241            matches: vec![SearchMatch {
13242                source_name: "msstore".to_owned(),
13243                source_kind: SourceKind::Rest,
13244                id: "9WZDNCRFJBMP".to_owned(),
13245                name: "Microsoft To Do".to_owned(),
13246                moniker: None,
13247                version: Some("2.123.456.0".to_owned()),
13248                channel: None,
13249                match_criteria: None,
13250            }],
13251            warnings: Vec::new(),
13252            truncated: false,
13253        };
13254
13255        let value = serde_json::to_value(&response).expect("serialize search response");
13256        let match_value = &value["Matches"][0];
13257
13258        assert_eq!(match_value["Name"], "Microsoft To Do");
13259        assert_eq!(match_value["Id"], "9WZDNCRFJBMP");
13260        assert_eq!(match_value["Version"], "2.123.456.0");
13261        assert_eq!(match_value["SourceName"], "msstore");
13262        assert!(match_value.get("source_name").is_none());
13263    }
13264
13265    #[test]
13266    fn versions_result_serialization_keeps_pascal_case_fields() {
13267        let result = VersionsResult {
13268            package: SearchMatch {
13269                source_name: "winget".to_owned(),
13270                source_kind: SourceKind::PreIndexed,
13271                id: "Microsoft.PowerToys".to_owned(),
13272                name: "PowerToys".to_owned(),
13273                moniker: None,
13274                version: Some("0.99.0".to_owned()),
13275                channel: None,
13276                match_criteria: None,
13277            },
13278            versions: vec![
13279                VersionKey {
13280                    version: "0.99.0".to_owned(),
13281                    channel: "stable".to_owned(),
13282                },
13283                VersionKey {
13284                    version: "0.100.0-preview".to_owned(),
13285                    channel: "preview".to_owned(),
13286                },
13287            ],
13288            warnings: Vec::new(),
13289        };
13290
13291        let value = serde_json::to_value(&result).expect("serialize versions result");
13292
13293        assert_eq!(value["Versions"][0]["Version"], "0.99.0");
13294        assert_eq!(value["Versions"][0]["Channel"], "stable");
13295        assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview");
13296        assert_eq!(value["Versions"][1]["Channel"], "preview");
13297        assert!(value.get("versions").is_none());
13298    }
13299
13300    #[test]
13301    fn version_data_parses_arp_bounds_from_winget_payload() {
13302        // Sanity check that aMiV/aMaV deserialize from a real-world
13303        // versionData.mszyml payload shape. Without `default`, packages
13304        // predating AppsAndFeaturesEntries would fail to parse.
13305        let payload = "sV: 1.0\nvD:\n- v: 10.0.300\n  aMiV: 10.3.26.23102\n  aMaV: 10.3.26.23102\n  rP: manifests/m/Microsoft/DotNet/SDK/10/10.0.300/4f87\n  s256H: 0e633f7fa41d0322ff185d25783951f2ed343f27965b165066d9f75d5689a48b\n- v: 9.0.0\n  rP: manifests/m/Microsoft/DotNet/SDK/10/9.0.0/0000\n  s256H: 0e633f7fa41d0322ff185d25783951f2ed343f27965b165066d9f75d5689a48b\n";
13306
13307        let document = serde_yaml::from_str::<PackageVersionDataDocument>(payload).expect("parse versionData.mszyml");
13308        assert_eq!(document.versions.len(), 2);
13309        assert_eq!(document.versions[0].arp_min_version.as_deref(), Some("10.3.26.23102"));
13310        assert_eq!(document.versions[0].arp_max_version.as_deref(), Some("10.3.26.23102"));
13311        assert!(document.versions[1].arp_min_version.is_none());
13312        assert!(document.versions[1].arp_max_version.is_none());
13313    }
13314
13315    #[test]
13316    fn msix_resource_string_name_resolves_to_catalog_name() {
13317        // App Installer's MSIX manifest stores DisplayName as
13318        // `ms-resource:appDisplayName`. Once we correlate it via PFN, we
13319        // know the catalog calls it `App Installer` — show that instead of
13320        // the unresolved placeholder, matching winget's output.
13321        let mut package = InstalledPackage {
13322            name: "ms-resource:appDisplayName".to_owned(),
13323            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
13324            installed_version: "1.28.239.0".to_owned(),
13325            publisher: None,
13326            scope: Some("User".to_owned()),
13327            installer_category: Some("msix".to_owned()),
13328            install_location: None,
13329            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
13330            product_codes: Vec::new(),
13331            upgrade_codes: Vec::new(),
13332            correlated: Some(SearchMatch {
13333                source_name: "winget".to_owned(),
13334                source_kind: SourceKind::PreIndexed,
13335                id: "Microsoft.AppInstaller".to_owned(),
13336                name: "App Installer".to_owned(),
13337                moniker: None,
13338                version: Some("1.28.240.0".to_owned()),
13339                channel: None,
13340                match_criteria: Some("PackageFamilyName".to_owned()),
13341            }),
13342            installed_version_canonical: false,
13343            correlated_requires_explicit_upgrade: false,
13344            correlated_lacks_compatible_installer: false,
13345        };
13346        apply_msix_resource_string_name_fix(&mut package);
13347        assert_eq!(package.name, "App Installer");
13348    }
13349
13350    #[test]
13351    fn msix_resource_string_fix_is_a_noop_when_not_msix() {
13352        // ARP rows can also carry unusual display names; the fix is gated
13353        // on the local_id prefix so a `ms-resource:` literal that somehow
13354        // appears in an ARP DisplayName (extremely unlikely, but possible)
13355        // doesn't get silently rewritten.
13356        let mut package = InstalledPackage {
13357            name: "ms-resource:appDisplayName".to_owned(),
13358            local_id: r"ARP\Machine\X64\{deadbeef}".to_owned(),
13359            installed_version: "1.0".to_owned(),
13360            publisher: None,
13361            scope: Some("Machine".to_owned()),
13362            installer_category: Some("msi".to_owned()),
13363            install_location: None,
13364            package_family_names: Vec::new(),
13365            product_codes: Vec::new(),
13366            upgrade_codes: Vec::new(),
13367            correlated: Some(SearchMatch {
13368                source_name: "winget".to_owned(),
13369                source_kind: SourceKind::PreIndexed,
13370                id: "Some.Package".to_owned(),
13371                name: "Should Not Apply".to_owned(),
13372                moniker: None,
13373                version: Some("1.0".to_owned()),
13374                channel: None,
13375                match_criteria: None,
13376            }),
13377            installed_version_canonical: false,
13378            correlated_requires_explicit_upgrade: false,
13379            correlated_lacks_compatible_installer: false,
13380        };
13381        apply_msix_resource_string_name_fix(&mut package);
13382        assert_eq!(package.name, "ms-resource:appDisplayName");
13383    }
13384
13385    #[test]
13386    fn msix_resource_string_fix_skips_resolved_names() {
13387        // The vast majority of MSIX entries have already-resolved names
13388        // (`Microsoft Teams`, `Notepad++`, etc.). The fix must not touch
13389        // those — `meta.name` and the installed name may legitimately
13390        // differ (`Microsoft Teams` vs `Microsoft Teams (work or school)`).
13391        let mut package = InstalledPackage {
13392            name: "Microsoft Teams".to_owned(),
13393            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
13394            installed_version: "25290.205.4069.4894".to_owned(),
13395            publisher: None,
13396            scope: Some("User".to_owned()),
13397            installer_category: Some("msix".to_owned()),
13398            install_location: None,
13399            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
13400            product_codes: Vec::new(),
13401            upgrade_codes: Vec::new(),
13402            correlated: Some(SearchMatch {
13403                source_name: "winget".to_owned(),
13404                source_kind: SourceKind::PreIndexed,
13405                id: "Microsoft.Teams".to_owned(),
13406                name: "Microsoft Teams Catalog Name".to_owned(),
13407                moniker: None,
13408                version: Some("26106.1906.4665.7308".to_owned()),
13409                channel: None,
13410                match_criteria: Some("PackageFamilyName".to_owned()),
13411            }),
13412            installed_version_canonical: false,
13413            correlated_requires_explicit_upgrade: false,
13414            correlated_lacks_compatible_installer: false,
13415        };
13416        apply_msix_resource_string_name_fix(&mut package);
13417        assert_eq!(package.name, "Microsoft Teams");
13418    }
13419
13420    #[cfg(windows)]
13421    #[test]
13422    fn unflip_packed_guid_reverses_msi_installer_packing() {
13423        // The MSI Installer hive packs GUIDs by char-reversing each of the 11
13424        // chunks (8/4/4/2*8). This is the encoding used inside
13425        // `HKLM\SOFTWARE\Classes\Installer\UpgradeCodes\<key>` subkey names
13426        // and their value names. Verified against the user's installed
13427        // Node.js ProductCode `{9292CBD9-...}` whose packed form (from the
13428        // live registry) is `9DBC2929593B4D2488740C8E00C4F652`.
13429        assert_eq!(
13430            unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F652").as_deref(),
13431            Some("{9292cbd9-b395-42d4-8847-c0e8004c6f25}")
13432        );
13433        // And the reverse case — Node.js UpgradeCode `{47c07a3a-...}` packed
13434        // form `A3A70C74FE2431248AD5F8A59570C782`.
13435        assert_eq!(
13436            unflip_packed_guid("A3A70C74FE2431248AD5F8A59570C782").as_deref(),
13437            Some("{47c07a3a-42ef-4213-a85d-8f5a59077c28}")
13438        );
13439        // Reject non-32-char or non-hex inputs.
13440        assert!(unflip_packed_guid("nothex").is_none());
13441        assert!(unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F65").is_none()); // 31 chars
13442        assert!(unflip_packed_guid("ZZZZZZZZ593B4D2488740C8E00C4F652").is_none());
13443    }
13444
13445    fn installed_with_correlated(id: &str, installed_version: &str, canonical: bool) -> InstalledPackage {
13446        InstalledPackage {
13447            name: format!("{id} install"),
13448            local_id: format!(r"ARP\Machine\X64\{id}"),
13449            installed_version: installed_version.to_owned(),
13450            publisher: None,
13451            scope: Some("Machine".to_owned()),
13452            installer_category: Some("msi".to_owned()),
13453            install_location: None,
13454            package_family_names: Vec::new(),
13455            product_codes: Vec::new(),
13456            upgrade_codes: Vec::new(),
13457            correlated: Some(SearchMatch {
13458                source_name: "winget".to_owned(),
13459                source_kind: SourceKind::PreIndexed,
13460                id: id.to_owned(),
13461                name: id.to_owned(),
13462                moniker: None,
13463                version: Some("99.0.0".to_owned()),
13464                channel: None,
13465                match_criteria: None,
13466            }),
13467            installed_version_canonical: canonical,
13468            correlated_requires_explicit_upgrade: false,
13469            correlated_lacks_compatible_installer: false,
13470        }
13471    }
13472
13473    #[test]
13474    fn dedupe_keeps_canonical_row_over_raw_arp_row() {
13475        // Two installed entries correlate to the same catalog id. One had its
13476        // ARP version remapped to a catalog Version via aMiV/aMaV (canonical);
13477        // the other did not (e.g. VS-installed .NET SDK whose ARP
13478        // `40.10.18029` doesn't fit any bucket). Without the canonical
13479        // preference, `compare_version` says `40.x.x.x > 10.0.108` so the
13480        // wrong row wins and the upgrade silently disappears.
13481        let raw = installed_with_correlated("Microsoft.DotNet.SDK.10", "40.10.18029", false);
13482        let canonical = installed_with_correlated("Microsoft.DotNet.SDK.10", "10.0.108", true);
13483
13484        let result = dedupe_correlated_for_upgrade(vec![raw, canonical]);
13485        assert_eq!(result.len(), 1);
13486        assert_eq!(result[0].installed_version, "10.0.108");
13487        assert!(result[0].installed_version_canonical);
13488    }
13489
13490    #[test]
13491    fn dedupe_keeps_highest_among_canonical_rows() {
13492        let lower = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.7", true);
13493        let higher = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.9", true);
13494
13495        let result = dedupe_correlated_for_upgrade(vec![lower, higher]);
13496        assert_eq!(result.len(), 1);
13497        assert_eq!(result[0].installed_version, "1.7.9");
13498    }
13499
13500    #[test]
13501    fn dedupe_leaves_uncorrelated_rows_alone() {
13502        // Uncorrelated entries don't reach upgrade output anyway (the
13503        // upgrade filter requires correlation), but the dedupe pass must not
13504        // drop them — `pinget list` could share this code path in the future.
13505        let uncorrelated = InstalledPackage {
13506            name: "Foo".to_owned(),
13507            local_id: r"ARP\Machine\X64\Foo".to_owned(),
13508            installed_version: "1.0".to_owned(),
13509            publisher: None,
13510            scope: Some("Machine".to_owned()),
13511            installer_category: Some("exe".to_owned()),
13512            install_location: None,
13513            package_family_names: Vec::new(),
13514            product_codes: Vec::new(),
13515            upgrade_codes: Vec::new(),
13516            correlated: None,
13517            installed_version_canonical: false,
13518            correlated_requires_explicit_upgrade: false,
13519            correlated_lacks_compatible_installer: false,
13520        };
13521        let result = dedupe_correlated_for_upgrade(vec![uncorrelated]);
13522        assert_eq!(result.len(), 1);
13523    }
13524}