Skip to main content

pinget_core/
lib.rs

1mod name_normalization;
2
3use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet, 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::OsStringExt;
12use std::path::{Path, PathBuf};
13use std::sync::RwLock;
14
15use anyhow::{Context, Result, anyhow, bail};
16use chrono::{DateTime, Duration, Utc};
17use reqwest::blocking::{Client, Response};
18use rusqlite::types::{Value as SqlValue, ValueRef};
19use rusqlite::{Connection, OpenFlags, Row as SqlRow, params_from_iter};
20use serde::{Deserialize, Serialize};
21use serde_json::{Map as JsonMap, Value as JsonValue};
22use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
23use sha2::{Digest, Sha256};
24#[cfg(windows)]
25use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree};
26#[cfg(windows)]
27use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
28#[cfg(windows)]
29use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
30#[cfg(windows)]
31use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
32#[cfg(windows)]
33use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
34#[cfg(windows)]
35use winreg::{RegKey, enums::*};
36use zip::ZipArchive;
37
38const DEFAULT_MARKET: &str = "US";
39const DEFAULT_MAX_RESULTS: usize = 50;
40const LIST_LOOKUP_MAX_RESULTS: usize = 500;
41const PREINDEXED_CANDIDATES: &[&str] = &["source2.msix", "source.msix"];
42const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1";
43#[cfg(windows)]
44const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe";
45#[cfg(windows)]
46const PACKAGED_NAME: &str = "Microsoft.DesktopAppInstaller";
47const INSTALLED_STATE_UNSUPPORTED_WARNING: &str =
48    "Installed package discovery is not supported on this platform; returning no installed packages.";
49const INSTALL_UNSUPPORTED_WARNING: &str =
50    "Installing packages is not supported on this platform; no changes were made.";
51const REPAIR_UNSUPPORTED_WARNING: &str = "Repairing packages is not supported on this platform; no changes were made.";
52const REPAIR_REINSTALL_WARNING: &str =
53    "Pinget repair currently re-runs the package install flow for the selected package.";
54const UNINSTALL_UNSUPPORTED_WARNING: &str =
55    "Uninstalling packages is not supported on this platform; no changes were made.";
56#[cfg(windows)]
57const WINGET_PACKAGE_NOT_FOUND_EXIT_CODE: i32 = -1978335212;
58const SUPPORTED_ADMIN_SETTINGS: &[&str] = &[
59    "LocalManifestFiles",
60    "BypassCertificatePinningForMicrosoftStore",
61    "InstallerHashOverride",
62    "LocalArchiveMalwareScanOverride",
63    "ProxyCommandLineOptions",
64];
65const REST_SUPPORTED_CONTRACTS: &[&str] = &[
66    "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",
67];
68
69#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
70pub enum SourceKind {
71    #[serde(rename = "PreIndexed", alias = "preIndexed")]
72    PreIndexed,
73    #[serde(rename = "Rest", alias = "rest")]
74    Rest,
75}
76
77impl Display for SourceKind {
78    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
79        match self {
80            SourceKind::PreIndexed => f.write_str("preindexed"),
81            SourceKind::Rest => f.write_str("rest"),
82        }
83    }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct SourceRecord {
88    pub name: String,
89    pub kind: SourceKind,
90    pub arg: String,
91    pub identifier: String,
92    #[serde(default = "default_source_trust_level")]
93    pub trust_level: String,
94    #[serde(default)]
95    pub explicit: bool,
96    #[serde(default)]
97    pub priority: i32,
98    #[serde(default)]
99    pub last_update: Option<DateTime<Utc>>,
100    #[serde(default)]
101    pub source_version: Option<String>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105struct SourceStore {
106    sources: Vec<SourceRecord>,
107}
108
109impl Default for SourceStore {
110    fn default() -> Self {
111        Self {
112            sources: vec![
113                SourceRecord {
114                    name: "winget".to_owned(),
115                    kind: SourceKind::PreIndexed,
116                    arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
117                    identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
118                    trust_level: "Trusted".to_owned(),
119                    explicit: false,
120                    priority: 0,
121                    last_update: None,
122                    source_version: None,
123                },
124                SourceRecord {
125                    name: "msstore".to_owned(),
126                    kind: SourceKind::Rest,
127                    arg: "https://storeedgefd.dsx.mp.microsoft.com/v9.0".to_owned(),
128                    identifier: "StoreEdgeFD".to_owned(),
129                    trust_level: "Trusted".to_owned(),
130                    explicit: false,
131                    priority: 0,
132                    last_update: None,
133                    source_version: None,
134                },
135            ],
136        }
137    }
138}
139
140/// Options for embedding the WinGet core library in another application.
141#[derive(Debug, Clone)]
142pub struct RepositoryOptions {
143    pub app_root: PathBuf,
144    pub user_agent: String,
145}
146
147impl RepositoryOptions {
148    /// Creates library options that keep all persistent state under the supplied root.
149    pub fn new(app_root: impl Into<PathBuf>) -> Self {
150        Self {
151            app_root: app_root.into(),
152            user_agent: DEFAULT_USER_AGENT.to_owned(),
153        }
154    }
155
156    /// Uses the default per-user app-data root that the CLI also uses.
157    pub fn for_current_user() -> Result<Self> {
158        Ok(Self::new(default_app_root()?))
159    }
160
161    /// Overrides the HTTP user-agent sent to source endpoints.
162    #[must_use]
163    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
164        self.user_agent = user_agent.into();
165        self
166    }
167}
168
169#[derive(Debug, Clone, Default)]
170pub struct PackageQuery {
171    pub query: Option<String>,
172    pub id: Option<String>,
173    pub name: Option<String>,
174    pub moniker: Option<String>,
175    pub tag: Option<String>,
176    pub command: Option<String>,
177    pub source: Option<String>,
178    pub count: Option<usize>,
179    pub exact: bool,
180    pub version: Option<String>,
181    pub channel: Option<String>,
182    pub locale: Option<String>,
183    pub installer_type: Option<String>,
184    pub installer_architecture: Option<String>,
185    pub platform: Option<String>,
186    pub os_version: Option<String>,
187    pub install_scope: Option<String>,
188}
189
190#[derive(Debug, Clone, Default)]
191pub struct ListQuery {
192    pub query: Option<String>,
193    pub id: Option<String>,
194    pub name: Option<String>,
195    pub moniker: Option<String>,
196    pub tag: Option<String>,
197    pub command: Option<String>,
198    pub product_code: Option<String>,
199    pub version: Option<String>,
200    pub source: Option<String>,
201    pub count: Option<usize>,
202    pub exact: bool,
203    pub install_scope: Option<String>,
204    pub upgrade_only: bool,
205    pub include_unknown: bool,
206    pub include_pinned: bool,
207}
208
209#[derive(Debug, Clone, serde::Serialize)]
210#[serde(rename_all = "PascalCase")]
211pub struct SearchMatch {
212    pub source_name: String,
213    pub source_kind: SourceKind,
214    pub id: String,
215    pub name: String,
216    pub moniker: Option<String>,
217    pub version: Option<String>,
218    pub channel: Option<String>,
219    pub match_criteria: Option<String>,
220}
221
222#[derive(Debug, Clone, serde::Serialize)]
223#[serde(rename_all = "PascalCase")]
224pub struct SearchResponse {
225    pub matches: Vec<SearchMatch>,
226    pub warnings: Vec<String>,
227    pub truncated: bool,
228}
229
230#[derive(Debug, Clone, serde::Serialize)]
231#[serde(rename_all = "PascalCase")]
232pub struct ListMatch {
233    pub name: String,
234    pub id: String,
235    pub local_id: String,
236    pub installed_version: String,
237    pub available_version: Option<String>,
238    pub source_name: Option<String>,
239    pub publisher: Option<String>,
240    pub scope: Option<String>,
241    pub installer_category: Option<String>,
242    pub install_location: Option<String>,
243    pub package_family_names: Vec<String>,
244    pub product_codes: Vec<String>,
245    pub upgrade_codes: Vec<String>,
246}
247
248#[derive(Debug, Clone, serde::Serialize)]
249#[serde(rename_all = "PascalCase")]
250pub struct ListResponse {
251    pub matches: Vec<ListMatch>,
252    pub warnings: Vec<String>,
253    pub truncated: bool,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
257#[serde(rename_all = "PascalCase")]
258pub struct VersionKey {
259    pub version: String,
260    pub channel: String,
261}
262
263#[derive(Debug, Clone, serde::Serialize)]
264pub struct Manifest {
265    pub id: String,
266    pub name: String,
267    pub version: String,
268    pub channel: String,
269    pub publisher: Option<String>,
270    pub description: Option<String>,
271    pub moniker: Option<String>,
272    pub package_url: Option<String>,
273    pub publisher_url: Option<String>,
274    pub publisher_support_url: Option<String>,
275    pub license: Option<String>,
276    pub license_url: Option<String>,
277    pub privacy_url: Option<String>,
278    pub author: Option<String>,
279    pub copyright: Option<String>,
280    pub copyright_url: Option<String>,
281    pub release_notes: Option<String>,
282    pub release_notes_url: Option<String>,
283    pub tags: Vec<String>,
284    pub agreements: Vec<PackageAgreement>,
285    pub package_dependencies: Vec<String>,
286    pub documentation: Vec<Documentation>,
287    pub installers: Vec<Installer>,
288    // `RequireExplicitUpgrade: true` opts a package out of bulk
289    // `pinget upgrade` output (winget parity). Users can still upgrade by
290    // explicit `id`. Set at top-level or per-installer; treated as true
291    // when any installer asserts it.
292    pub require_explicit_upgrade: bool,
293}
294
295#[derive(Debug, Clone, serde::Serialize)]
296pub struct Documentation {
297    pub label: Option<String>,
298    pub url: String,
299}
300
301#[derive(Debug, Clone, serde::Serialize)]
302pub struct PackageAgreement {
303    pub label: Option<String>,
304    pub text: Option<String>,
305    pub url: Option<String>,
306}
307
308#[derive(Debug, Clone, serde::Serialize)]
309pub struct Installer {
310    pub architecture: Option<String>,
311    pub installer_type: Option<String>,
312    pub url: Option<String>,
313    pub sha256: Option<String>,
314    pub product_code: Option<String>,
315    pub locale: Option<String>,
316    pub scope: Option<String>,
317    pub release_date: Option<String>,
318    pub package_family_name: Option<String>,
319    pub upgrade_code: Option<String>,
320    pub platforms: Vec<String>,
321    pub minimum_os_version: Option<String>,
322    pub switches: InstallerSwitches,
323    pub commands: Vec<String>,
324    pub package_dependencies: Vec<String>,
325    pub require_explicit_upgrade: bool,
326}
327
328#[derive(Debug, Clone, Default, serde::Serialize)]
329pub struct InstallerSwitches {
330    pub silent: Option<String>,
331    pub silent_with_progress: Option<String>,
332    pub interactive: Option<String>,
333    pub custom: Option<String>,
334    pub log: Option<String>,
335    pub install_location: Option<String>,
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub enum InstallerMode {
340    Interactive,
341    SilentWithProgress,
342    Silent,
343}
344
345#[derive(Debug, Clone)]
346pub struct InstallRequest {
347    pub query: PackageQuery,
348    pub manifest_path: Option<PathBuf>,
349    pub mode: InstallerMode,
350    pub log_path: Option<PathBuf>,
351    pub custom: Option<String>,
352    pub override_args: Option<String>,
353    pub install_location: Option<String>,
354    pub skip_dependencies: bool,
355    pub dependencies_only: bool,
356    pub accept_package_agreements: bool,
357    pub force: bool,
358    pub rename: Option<String>,
359    pub uninstall_previous: bool,
360    pub ignore_security_hash: bool,
361    pub dependency_source: Option<String>,
362    pub no_upgrade: bool,
363}
364
365impl InstallRequest {
366    pub fn new(query: PackageQuery) -> Self {
367        Self {
368            query,
369            manifest_path: None,
370            mode: InstallerMode::SilentWithProgress,
371            log_path: None,
372            custom: None,
373            override_args: None,
374            install_location: None,
375            skip_dependencies: false,
376            dependencies_only: false,
377            accept_package_agreements: false,
378            force: false,
379            rename: None,
380            uninstall_previous: false,
381            ignore_security_hash: false,
382            dependency_source: None,
383            no_upgrade: false,
384        }
385    }
386}
387
388#[derive(Debug, Clone)]
389pub struct UninstallRequest {
390    pub query: PackageQuery,
391    pub manifest_path: Option<PathBuf>,
392    pub product_code: Option<String>,
393    pub mode: InstallerMode,
394    pub all_versions: bool,
395    pub force: bool,
396    pub purge: bool,
397    pub preserve: bool,
398    pub log_path: Option<PathBuf>,
399}
400
401impl UninstallRequest {
402    pub fn new(query: PackageQuery) -> Self {
403        Self {
404            query,
405            manifest_path: None,
406            product_code: None,
407            mode: InstallerMode::SilentWithProgress,
408            all_versions: false,
409            force: false,
410            purge: false,
411            preserve: false,
412            log_path: None,
413        }
414    }
415}
416
417#[derive(Debug, Clone)]
418pub struct RepairRequest {
419    pub query: PackageQuery,
420    pub manifest_path: Option<PathBuf>,
421    pub product_code: Option<String>,
422    pub mode: InstallerMode,
423    pub log_path: Option<PathBuf>,
424    pub accept_package_agreements: bool,
425    pub force: bool,
426    pub ignore_security_hash: bool,
427}
428
429impl RepairRequest {
430    pub fn new(query: PackageQuery) -> Self {
431        Self {
432            query,
433            manifest_path: None,
434            product_code: None,
435            mode: InstallerMode::SilentWithProgress,
436            log_path: None,
437            accept_package_agreements: false,
438            force: false,
439            ignore_security_hash: false,
440        }
441    }
442}
443
444#[derive(Debug, Clone, serde::Serialize)]
445pub struct ShowResult {
446    pub package: SearchMatch,
447    pub manifest: Manifest,
448    pub selected_installer: Option<Installer>,
449    pub cached_files: Vec<PathBuf>,
450    pub warnings: Vec<String>,
451    #[serde(skip_serializing)]
452    manifest_documents: JsonValue,
453}
454
455impl ShowResult {
456    pub fn structured_document(&self) -> JsonValue {
457        collapse_structured_document(&self.manifest_documents)
458    }
459}
460
461#[derive(Debug, Clone, serde::Serialize)]
462#[serde(rename_all = "PascalCase")]
463pub struct VersionsResult {
464    pub package: SearchMatch,
465    pub versions: Vec<VersionKey>,
466    pub warnings: Vec<String>,
467}
468
469#[derive(Debug, Clone, serde::Serialize)]
470#[serde(rename_all = "PascalCase")]
471pub struct CacheWarmResult {
472    pub package: SearchMatch,
473    pub cached_files: Vec<PathBuf>,
474    pub warnings: Vec<String>,
475}
476
477#[derive(Debug, Clone, serde::Serialize)]
478#[serde(rename_all = "PascalCase")]
479pub struct SourceUpdateResult {
480    pub name: String,
481    pub kind: SourceKind,
482    pub detail: String,
483}
484
485#[derive(Debug, Clone, serde::Serialize)]
486#[serde(rename_all = "PascalCase")]
487pub struct PinRecord {
488    pub package_id: String,
489    pub version: String,
490    pub source_id: String,
491    pub pin_type: PinType,
492}
493
494#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
495pub enum PinType {
496    Pinning,
497    Blocking,
498    Gating,
499}
500
501impl Display for PinType {
502    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
503        match self {
504            PinType::Pinning => f.write_str("Pinning"),
505            PinType::Blocking => f.write_str("Blocking"),
506            PinType::Gating => f.write_str("Gating"),
507        }
508    }
509}
510
511/// Result of an install/uninstall/upgrade operation
512#[derive(Debug, Clone, serde::Serialize)]
513pub struct InstallResult {
514    pub package_id: String,
515    pub version: String,
516    pub installer_path: PathBuf,
517    pub installer_type: String,
518    pub exit_code: i32,
519    pub success: bool,
520    pub no_op: bool,
521    pub warnings: Vec<String>,
522}
523
524impl InstallerSwitches {
525    fn with_fallback(&self, fallback: &Self) -> Self {
526        Self {
527            silent: self.silent.clone().or_else(|| fallback.silent.clone()),
528            silent_with_progress: self
529                .silent_with_progress
530                .clone()
531                .or_else(|| fallback.silent_with_progress.clone()),
532            interactive: self.interactive.clone().or_else(|| fallback.interactive.clone()),
533            custom: self.custom.clone().or_else(|| fallback.custom.clone()),
534            log: self.log.clone().or_else(|| fallback.log.clone()),
535            install_location: self
536                .install_location
537                .clone()
538                .or_else(|| fallback.install_location.clone()),
539        }
540    }
541}
542
543fn installed_package_discovery_supported() -> bool {
544    cfg!(windows)
545}
546
547fn package_actions_supported() -> bool {
548    cfg!(windows)
549}
550
551fn unsupported_action_result(
552    package_id: impl Into<String>,
553    version: impl Into<String>,
554    installer_type: impl Into<String>,
555    warning: &str,
556) -> InstallResult {
557    InstallResult {
558        package_id: package_id.into(),
559        version: version.into(),
560        installer_path: PathBuf::new(),
561        installer_type: installer_type.into(),
562        exit_code: 0,
563        success: true,
564        no_op: true,
565        warnings: vec![warning.to_owned()],
566    }
567}
568
569#[derive(Debug, Clone)]
570struct InstalledPackage {
571    name: String,
572    local_id: String,
573    installed_version: String,
574    publisher: Option<String>,
575    scope: Option<String>,
576    installer_category: Option<String>,
577    install_location: Option<String>,
578    package_family_names: Vec<String>,
579    product_codes: Vec<String>,
580    upgrade_codes: Vec<String>,
581    correlated: Option<SearchMatch>,
582    // True when `installed_version` was rewritten to a catalog Version via
583    // versionData.mszyml's aMiV/aMaV range. Such versions compare meaningfully
584    // against the catalog's Version; raw ARP DisplayVersions usually don't.
585    // Used by dedupe so that, when the same id has both a canonical and a raw
586    // install row, we prefer the canonical (e.g. `Microsoft.DotNet.SDK.10`
587    // 10.0.108 over the VS-installed 40.10.18029 that doesn't map).
588    installed_version_canonical: bool,
589    // True when the correlated catalog package's latest version sets
590    // `RequireExplicitUpgrade: true`. winget hides those rows from bulk
591    // `upgrade`; we mirror that. Users can still upgrade by explicit id.
592    correlated_requires_explicit_upgrade: bool,
593    // True when the correlated catalog package's latest version has no
594    // installer for an architecture the user can actually run. winget
595    // hides those from `upgrade`; we mirror that.
596    correlated_lacks_compatible_installer: bool,
597}
598
599#[derive(Debug, Clone)]
600struct LocatedMatch {
601    display: SearchMatch,
602    source_index: usize,
603    locator: MatchLocator,
604}
605
606#[derive(Debug)]
607struct SearchSourceMatches {
608    matches: Vec<LocatedMatch>,
609    truncated: bool,
610}
611
612#[derive(Debug, Clone)]
613enum MatchLocator {
614    PreIndexedV1 {
615        package_rowid: i64,
616    },
617    PreIndexedV2 {
618        package_rowid: i64,
619        package_hash: String,
620    },
621    Rest {
622        package_id: String,
623        versions: Vec<VersionKey>,
624    },
625}
626
627#[derive(Debug, Clone, Copy, PartialEq, Eq)]
628enum SearchSemantics {
629    Many,
630    Single,
631}
632
633#[derive(Debug, Clone, Serialize, Deserialize)]
634struct RestInfoCache {
635    expires_at: DateTime<Utc>,
636    value: RestInformation,
637}
638
639#[derive(Debug, Clone, Default, Serialize, Deserialize)]
640struct RestInformation {
641    #[serde(rename = "SourceIdentifier")]
642    source_identifier: String,
643    #[serde(rename = "ServerSupportedVersions", default)]
644    server_supported_versions: Vec<String>,
645    #[serde(rename = "RequiredPackageMatchFields", default)]
646    required_package_match_fields: Vec<String>,
647    #[serde(rename = "UnsupportedPackageMatchFields", default)]
648    unsupported_package_match_fields: Vec<String>,
649    #[serde(rename = "RequiredQueryParameters", default)]
650    required_query_parameters: Vec<String>,
651    #[serde(rename = "UnsupportedQueryParameters", default)]
652    unsupported_query_parameters: Vec<String>,
653}
654
655#[derive(Debug, Clone, Deserialize)]
656struct PackageVersionDataDocument {
657    #[serde(rename = "vD", default)]
658    versions: Vec<PackageVersionDataEntry>,
659}
660
661#[derive(Debug, Clone, Deserialize)]
662struct PackageVersionDataEntry {
663    #[serde(rename = "v")]
664    version: String,
665    #[serde(rename = "rP")]
666    manifest_relative_path: String,
667    #[serde(rename = "s256H")]
668    manifest_hash: String,
669    // ARP version range covered by this catalog version. WinGet writes these
670    // into versionData.mszyml so an installed ARP DisplayVersion (which may be
671    // an MSI build number with no relation to the catalog Version) can be
672    // mapped back to the catalog Version it corresponds to.
673    #[serde(rename = "aMiV", default)]
674    arp_min_version: Option<String>,
675    #[serde(rename = "aMaV", default)]
676    arp_max_version: Option<String>,
677}
678
679pub struct Repository {
680    app_root: PathBuf,
681    client: Client,
682    store: SourceStore,
683    use_system_winget_sources: bool,
684}
685
686impl Repository {
687    /// Opens the repository with the default per-user CLI storage layout.
688    pub fn open() -> Result<Self> {
689        Self::open_with_options(RepositoryOptions::for_current_user()?)
690    }
691
692    /// Opens the repository with explicit hosting options for library consumers.
693    pub fn open_with_options(options: RepositoryOptions) -> Result<Self> {
694        ensure_app_dirs(&options.app_root)?;
695        let use_system_winget_sources = uses_system_winget_source_commands(&options.app_root);
696        let store = load_store(&options.app_root)?;
697        let client = Client::builder()
698            .user_agent(&options.user_agent)
699            .build()
700            .context("failed to build HTTP client")?;
701        Ok(Self {
702            app_root: options.app_root,
703            client,
704            store,
705            use_system_winget_sources,
706        })
707    }
708
709    pub fn app_root(&self) -> &Path {
710        &self.app_root
711    }
712
713    pub fn list_sources(&self) -> Vec<SourceRecord> {
714        self.store.sources.clone()
715    }
716
717    pub fn add_source(&mut self, name: &str, arg: &str, kind: SourceKind) -> Result<()> {
718        self.add_source_with_metadata(name, arg, kind, None, false, 0)
719    }
720
721    pub fn add_source_with_metadata(
722        &mut self,
723        name: &str,
724        arg: &str,
725        kind: SourceKind,
726        trust_level: Option<&str>,
727        explicit: bool,
728        priority: i32,
729    ) -> Result<()> {
730        if self.use_system_winget_sources {
731            system_winget_add_source(name, arg, kind, &normalize_source_trust_level(trust_level)?, explicit)?;
732            self.refresh_system_winget_sources()?;
733            return Ok(());
734        }
735
736        if self.store.sources.iter().any(|s| s.name.eq_ignore_ascii_case(name)) {
737            bail!("A source with name '{name}' already exists.");
738        }
739        if self.store.sources.iter().any(|s| s.arg == arg) {
740            bail!("A source with argument '{arg}' already exists.");
741        }
742        let identifier = name.to_owned();
743        self.store.sources.push(SourceRecord {
744            name: name.to_owned(),
745            kind,
746            arg: arg.to_owned(),
747            identifier,
748            trust_level: normalize_source_trust_level(trust_level)?,
749            explicit,
750            priority,
751            last_update: None,
752            source_version: None,
753        });
754        self.save_store()?;
755        Ok(())
756    }
757
758    pub fn edit_source(&mut self, name: &str, explicit: Option<bool>, trust_level: Option<&str>) -> Result<()> {
759        if self.use_system_winget_sources {
760            bail!("Editing system WinGet sources is not supported by winget source commands.");
761        }
762
763        let source = self
764            .store
765            .sources
766            .iter_mut()
767            .find(|source| source.name.eq_ignore_ascii_case(name))
768            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
769
770        if let Some(explicit) = explicit {
771            source.explicit = explicit;
772        }
773
774        if trust_level.is_some() {
775            source.trust_level = normalize_source_trust_level(trust_level)?;
776        }
777
778        self.save_store()?;
779        Ok(())
780    }
781
782    pub fn remove_source(&mut self, name: &str) -> Result<()> {
783        if self.use_system_winget_sources {
784            system_winget_remove_source(name)?;
785            self.refresh_system_winget_sources()?;
786            return Ok(());
787        }
788
789        let idx = self
790            .store
791            .sources
792            .iter()
793            .position(|s| s.name.eq_ignore_ascii_case(name))
794            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
795        let source = self.store.sources.remove(idx);
796        // Clean up cached state for this source
797        let state_dir = self.source_state_dir(&source);
798        if state_dir.exists() {
799            let _ = fs::remove_dir_all(&state_dir);
800        }
801        self.save_store()?;
802        Ok(())
803    }
804
805    pub fn reset_source(&mut self, name: &str) -> Result<()> {
806        if self.use_system_winget_sources {
807            system_winget_reset_source(name)?;
808            self.refresh_system_winget_sources()?;
809            return Ok(());
810        }
811
812        let idx = self
813            .store
814            .sources
815            .iter()
816            .position(|source| source.name.eq_ignore_ascii_case(name))
817            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
818
819        let source = self.store.sources[idx].clone();
820        let state_dir = self.source_state_dir(&source);
821        if state_dir.exists() {
822            let _ = fs::remove_dir_all(&state_dir);
823        }
824
825        if let Some(default_source) = SourceStore::default()
826            .sources
827            .into_iter()
828            .find(|source| source.name.eq_ignore_ascii_case(name))
829        {
830            self.store.sources[idx] = default_source;
831        } else {
832            self.store.sources[idx].last_update = None;
833            self.store.sources[idx].source_version = None;
834        }
835
836        self.save_store()?;
837        Ok(())
838    }
839
840    pub fn reset_sources(&mut self) -> Result<()> {
841        if self.use_system_winget_sources {
842            system_winget_reset_sources()?;
843            self.refresh_system_winget_sources()?;
844            return Ok(());
845        }
846
847        // Remove cached state for all current sources
848        for source in &self.store.sources {
849            let state_dir = self.source_state_dir(source);
850            if state_dir.exists() {
851                let _ = fs::remove_dir_all(&state_dir);
852            }
853        }
854        self.store = SourceStore::default();
855        self.save_store()?;
856        Ok(())
857    }
858
859    pub fn supported_admin_settings() -> &'static [&'static str] {
860        SUPPORTED_ADMIN_SETTINGS
861    }
862
863    pub fn get_user_settings(&self) -> Result<JsonValue> {
864        Ok(JsonValue::Object(load_json_object(&user_settings_path(
865            &self.app_root,
866        ))?))
867    }
868
869    pub fn set_user_settings(&self, user_settings: &JsonValue, merge: bool) -> Result<JsonValue> {
870        let update = match user_settings {
871            JsonValue::Object(object) => object.clone(),
872            _ => bail!("user settings must be a JSON object"),
873        };
874
875        let effective = if merge {
876            merge_json_objects(&load_json_object(&user_settings_path(&self.app_root))?, &update)
877        } else {
878            update
879        };
880        save_json_object(user_settings_path(&self.app_root), &effective)?;
881        Ok(JsonValue::Object(effective))
882    }
883
884    pub fn test_user_settings(&self, expected: &JsonValue, ignore_not_set: bool) -> Result<bool> {
885        let current = self.get_user_settings()?;
886        Ok(if ignore_not_set {
887            json_contains(&current, expected)
888        } else {
889            current == *expected
890        })
891    }
892
893    pub fn get_admin_settings(&self) -> Result<JsonValue> {
894        let mut settings = load_json_object(&admin_settings_path(&self.app_root))?;
895        for name in SUPPORTED_ADMIN_SETTINGS {
896            settings.entry((*name).to_owned()).or_insert(JsonValue::Bool(false));
897        }
898
899        Ok(JsonValue::Object(settings))
900    }
901
902    pub fn set_admin_setting(&self, name: &str, enabled: bool) -> Result<()> {
903        let normalized = normalize_admin_setting_name(name)?;
904        let mut settings = match self.get_admin_settings()? {
905            JsonValue::Object(settings) => settings,
906            _ => JsonMap::new(),
907        };
908        settings.insert(normalized.to_owned(), JsonValue::Bool(enabled));
909        save_json_object(admin_settings_path(&self.app_root), &settings)
910    }
911
912    pub fn reset_admin_setting(&self, name: Option<&str>, reset_all: bool) -> Result<()> {
913        if !reset_all && name.is_none() {
914            bail!("resetting admin settings requires a setting name or reset-all");
915        }
916
917        let mut settings = match self.get_admin_settings()? {
918            JsonValue::Object(settings) => settings,
919            _ => JsonMap::new(),
920        };
921
922        if reset_all {
923            for setting_name in SUPPORTED_ADMIN_SETTINGS {
924                settings.insert((*setting_name).to_owned(), JsonValue::Bool(false));
925            }
926        } else {
927            settings.insert(
928                normalize_admin_setting_name(name.expect("checked above"))?.to_owned(),
929                JsonValue::Bool(false),
930            );
931        }
932
933        save_json_object(admin_settings_path(&self.app_root), &settings)
934    }
935
936    pub fn ensure_settings_files(&self) -> Result<()> {
937        let user_settings = user_settings_path(&self.app_root);
938        if !user_settings.exists() {
939            save_json_object(user_settings, &JsonMap::new())?;
940        }
941
942        let admin_settings = admin_settings_path(&self.app_root);
943        if !admin_settings.exists() {
944            let mut values = JsonMap::new();
945            for name in SUPPORTED_ADMIN_SETTINGS {
946                values.insert((*name).to_owned(), JsonValue::Bool(false));
947            }
948            save_json_object(admin_settings, &values)?;
949        }
950
951        Ok(())
952    }
953
954    pub fn update_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
955        if self.use_system_winget_sources {
956            return self.update_system_winget_sources(source_name);
957        }
958
959        let indexes = self.resolve_source_indexes(source_name)?;
960        let mut results = Vec::new();
961
962        for index in indexes {
963            let detail = match self.store.sources[index].kind {
964                SourceKind::PreIndexed => self.update_preindexed(index)?,
965                SourceKind::Rest => self.update_rest(index)?,
966            };
967
968            results.push(SourceUpdateResult {
969                name: self.store.sources[index].name.clone(),
970                kind: self.store.sources[index].kind,
971                detail,
972            });
973        }
974
975        self.save_store()?;
976        Ok(results)
977    }
978
979    pub fn search(&mut self, query: &PackageQuery) -> Result<SearchResponse> {
980        let (matches, warnings, truncated) = self.search_located(query, SearchSemantics::Many)?;
981        Ok(SearchResponse {
982            matches: matches.into_iter().map(|item| item.display).collect(),
983            warnings,
984            truncated,
985        })
986    }
987
988    pub fn search_manifests(&mut self, query: &PackageQuery) -> Result<Vec<JsonValue>> {
989        let (matches, _, _) = self.search_located(query, SearchSemantics::Many)?;
990        let mut structured_documents = Vec::with_capacity(matches.len());
991        for located in matches {
992            let (_, manifest_documents, _) = self.manifest_for_match(&located, query)?;
993            structured_documents.push(manifest_documents);
994        }
995
996        Ok(collapse_structured_documents(&structured_documents))
997    }
998
999    pub fn list(&mut self, query: &ListQuery) -> Result<ListResponse> {
1000        if (query.include_unknown || query.include_pinned) && !query.upgrade_only {
1001            bail!("--include-unknown and --include-pinned require --upgrade-available");
1002        }
1003        if query.source.is_some()
1004            && query.query.is_none()
1005            && query.id.is_none()
1006            && query.name.is_none()
1007            && query.moniker.is_none()
1008            && query.tag.is_none()
1009            && query.command.is_none()
1010        {
1011            bail!("list --source currently requires a query or explicit filter");
1012        }
1013
1014        let has_filter = list_query_needs_available_lookup(query);
1015        // Plain `list` should still correlate installed packages back to their
1016        // catalog identities so callers see canonical package ids/source names,
1017        // matching winget and the fixed C# Pinget behavior. `upgrade_only`
1018        // still adds the available-version specific filtering on top.
1019        let needs_available = true;
1020
1021        let mut warnings = Vec::new();
1022        if !installed_package_discovery_supported() {
1023            warnings.push(INSTALLED_STATE_UNSUPPORTED_WARNING.to_owned());
1024        }
1025        let mut installed = collect_installed_packages(query.install_scope.as_deref())?;
1026
1027        if needs_available {
1028            // Authoritative correlation via the v2 index's identity tables
1029            // (PackageFamilyName / ProductCode / UpgradeCode). This is winget's
1030            // primary path and resolves cases where display-name matching is
1031            // ambiguous (Microsoft.Teams vs Microsoft.Teams.Free) or impossible
1032            // (MSIX with `ms-resource:` placeholder names).
1033            warnings.extend(self.correlate_installed_via_index(&mut installed, query.source.as_deref())?);
1034
1035            // ARP entries that lack identity keys (no PFN/PC/UC) still match
1036            // winget's ARP correlation when their (DisplayName, Publisher),
1037            // run through the NameNormalizer, lands on a single package in
1038            // norm_names2 ∩ norm_publishers2. Covers Inno Setup-style
1039            // installers, MSIs whose ARP keys don't include ProductCode, and
1040            // any vendor that publishes a DisplayName that differs from the
1041            // catalog's PackageName but matches an AppsAndFeaturesEntries
1042            // DisplayName.
1043            warnings.extend(self.correlate_installed_by_normalized_identity(&mut installed, query.source.as_deref())?);
1044        }
1045
1046        if needs_available && has_filter {
1047            // Filtered lookup: search sources with the user's query for any
1048            // installed package not already resolved by identity.
1049            let available_query = package_query_from_list_query(query);
1050            let (matches, source_warnings, _) = self.search_located(&available_query, SearchSemantics::Many)?;
1051            warnings.extend(source_warnings);
1052            let candidates: Vec<SearchMatch> = matches.into_iter().map(|c| c.display).collect();
1053            for package in &mut installed {
1054                if package.correlated.is_some() {
1055                    continue;
1056                }
1057                package.correlated =
1058                    correlate_installed_package(package, &candidates, allow_loose_list_correlation(query));
1059            }
1060        } else if needs_available {
1061            // Unfiltered upgrade: look up each installed package by its correlation names
1062            warnings.extend(self.correlate_all_installed(&mut installed)?);
1063        }
1064
1065        if needs_available {
1066            // Enrich rows that correlated via the name-based fallback. Those
1067            // rows skipped the v2-index aMiV/aMaV pass that runs inside
1068            // `correlate_installed_via_index`, so they still carry the raw
1069            // ARP DisplayVersion (e.g. `JetBrains Rider 2025.3.0.1` showing
1070            // build `253.28294.112` instead of the marketing `2025.3.0.1`).
1071            // Also resolves MSIX placeholder names like
1072            // `ms-resource:appDisplayName` to the catalog's display name —
1073            // winget pulls these through Windows.Management.Deployment; the
1074            // catalog name is the parity-faithful substitute pinget has
1075            // available without taking a WinRT dependency.
1076            warnings.extend(self.enrich_correlated_via_index(&mut installed, query.source.as_deref())?);
1077        }
1078
1079        // For `upgrade`, collapse multiple installed entries that map to the
1080        // same catalog package id (different ARP rows for side-by-side .NET
1081        // SDKs, several runtime versions of WindowsAppRuntime, the MSIX shim
1082        // alongside an ARP install of Edge, etc.). Keep the entry with the
1083        // highest installed_version so the upgrade comparison runs against
1084        // the user's newest install — matches winget's one-row-per-id output.
1085        if query.upgrade_only {
1086            installed = dedupe_correlated_for_upgrade(installed);
1087        }
1088
1089        let mut matches = installed
1090            .into_iter()
1091            .filter(|package| {
1092                list_package_matches(package, query)
1093                    && (!query.upgrade_only || installed_package_matches_upgrade_filter(package, query))
1094            })
1095            .collect::<Vec<_>>();
1096
1097        matches.sort_by(|left, right| {
1098            list_sort_weight(left)
1099                .cmp(&list_sort_weight(right))
1100                .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
1101                .then_with(|| left.local_id.cmp(&right.local_id))
1102        });
1103
1104        let pins = if query.upgrade_only {
1105            self.list_pins(query.source.as_deref())?
1106        } else {
1107            Vec::new()
1108        };
1109        let mut list_matches = matches
1110            .into_iter()
1111            .map(list_match_from_installed)
1112            .filter(|item| !query.upgrade_only || query.include_pinned || !is_upgrade_blocked_by_pin(item, &pins))
1113            .collect::<Vec<_>>();
1114        let truncated = if let Some(limit) = query.count {
1115            let was_truncated = list_matches.len() > limit;
1116            list_matches.truncate(limit);
1117            was_truncated
1118        } else {
1119            false
1120        };
1121
1122        Ok(ListResponse {
1123            matches: list_matches,
1124            warnings,
1125            truncated,
1126        })
1127    }
1128
1129    /// For unfiltered upgrade/list, search the entire available index and correlate
1130    /// against all installed packages. Skips packages already correlated via the
1131    /// v2 identity tables.
1132    fn correlate_all_installed(&mut self, installed: &mut [InstalledPackage]) -> Result<Vec<String>> {
1133        if installed.iter().all(|p| p.correlated.is_some()) {
1134            return Ok(Vec::new());
1135        }
1136        let all_query = PackageQuery {
1137            query: None,
1138            id: None,
1139            name: None,
1140            moniker: None,
1141            tag: None,
1142            command: None,
1143            source: None,
1144            count: Some(100_000), // fetch the entire index
1145            exact: false,
1146            version: None,
1147            channel: None,
1148            locale: None,
1149            installer_type: None,
1150            installer_architecture: None,
1151            platform: None,
1152            os_version: None,
1153            install_scope: None,
1154        };
1155        let (matches, warnings, _) = self.search_located(&all_query, SearchSemantics::Many)?;
1156        let candidates: Vec<SearchMatch> = matches.into_iter().map(|c| c.display).collect();
1157
1158        for package in installed.iter_mut() {
1159            if package.correlated.is_some() {
1160                continue;
1161            }
1162            package.correlated = correlate_installed_package(package, &candidates, true);
1163        }
1164
1165        Ok(warnings)
1166    }
1167
1168    /// Correlates installed packages against the v2 pre-indexed catalog using
1169    /// PackageFamilyName / ProductCode / UpgradeCode lookups — winget's
1170    /// authoritative correlation path. Also rewrites `installed_version` to
1171    /// the catalog Version whose ARP range covers the ARP DisplayVersion, so
1172    /// packages whose ARP version is an MSI build number (e.g. .NET SDK,
1173    /// Python) compare against a meaningful catalog version. Packages without
1174    /// any of the three identifiers, or whose identifiers don't match a
1175    /// catalog entry, are left untouched for the name-based fallback.
1176    fn correlate_installed_via_index(
1177        &mut self,
1178        installed: &mut [InstalledPackage],
1179        requested_source: Option<&str>,
1180    ) -> Result<Vec<String>> {
1181        let source_indices: Vec<usize> = self
1182            .store
1183            .sources
1184            .iter()
1185            .enumerate()
1186            .filter(|(_, source)| {
1187                source.kind == SourceKind::PreIndexed
1188                    && match requested_source {
1189                        Some(name) => source.name.eq_ignore_ascii_case(name),
1190                        None => true,
1191                    }
1192            })
1193            .map(|(index, _)| index)
1194            .collect();
1195
1196        for source_index in source_indices {
1197            let resolved: Vec<(usize, V2PackageMetadata, &'static str)> = {
1198                let connection = match self.open_preindexed_connection(source_index) {
1199                    Ok(c) => c,
1200                    Err(_) => continue,
1201                };
1202                if !v2_identity_tables_present(&connection) {
1203                    continue;
1204                }
1205                let mut acc = Vec::new();
1206                for (idx, package) in installed.iter().enumerate() {
1207                    if package.correlated.is_some() {
1208                        continue;
1209                    }
1210                    let Some((rowid, by)) = lookup_identity_match_v2(&connection, package)? else {
1211                        continue;
1212                    };
1213                    let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1214                        continue;
1215                    };
1216                    acc.push((idx, meta, by));
1217                }
1218                acc
1219            };
1220
1221            let source = self.source_clone(source_index);
1222            for (idx, meta, by) in resolved {
1223                let (canonical_installed, anchored_latest) =
1224                    match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1225                        Ok((entries, _)) => (
1226                            map_arp_version_to_catalog(&entries, &installed[idx].installed_version),
1227                            latest_arp_anchored_version(&entries),
1228                        ),
1229                        Err(_) => (None, None),
1230                    };
1231                // When the installed side was rebased to a catalog Version via
1232                // aMiV/aMaV, the available side must compare on the same scale:
1233                // prefer the latest ARP-anchored Version over `latest_version`,
1234                // which can be an internal/MSI build number (e.g. WinAppRuntime
1235                // 1.8's `8000.836.2153.0`) that would manufacture a phantom
1236                // upgrade against an installed canonical like `1.8.6`.
1237                let available_version = match (&canonical_installed, anchored_latest) {
1238                    (Some(_), Some(anchored)) => anchored,
1239                    _ => meta.latest_version,
1240                };
1241                let installed_pkg = &mut installed[idx];
1242                installed_pkg.correlated = Some(SearchMatch {
1243                    source_name: source.name.clone(),
1244                    source_kind: source.kind,
1245                    id: meta.id,
1246                    name: meta.name,
1247                    moniker: meta.moniker,
1248                    version: Some(available_version),
1249                    channel: None,
1250                    match_criteria: Some(by.to_owned()),
1251                });
1252                if let Some(version) = canonical_installed {
1253                    installed_pkg.installed_version = version;
1254                    installed_pkg.installed_version_canonical = true;
1255                }
1256            }
1257        }
1258
1259        Ok(Vec::new())
1260    }
1261
1262    /// Mid-tier correlation that mirrors winget's ARP normalization path.
1263    /// For each installed package without a PFN/PC/UC identity match, the
1264    /// (DisplayName, Publisher) is run through the NameNormalizer and
1265    /// intersected against the v2 index's `norm_names2` and
1266    /// `norm_publishers2` tables. A single resulting `package_rowid`
1267    /// produces a correlation; ambiguous matches are skipped, matching
1268    /// winget's empty-Source behavior when normalization can't pick a
1269    /// winner.
1270    fn correlate_installed_by_normalized_identity(
1271        &mut self,
1272        installed: &mut [InstalledPackage],
1273        requested_source: Option<&str>,
1274    ) -> Result<Vec<String>> {
1275        let source_indices: Vec<usize> = self
1276            .store
1277            .sources
1278            .iter()
1279            .enumerate()
1280            .filter(|(_, source)| {
1281                source.kind == SourceKind::PreIndexed
1282                    && match requested_source {
1283                        Some(name) => source.name.eq_ignore_ascii_case(name),
1284                        None => true,
1285                    }
1286            })
1287            .map(|(index, _)| index)
1288            .collect();
1289
1290        for source_index in source_indices {
1291            let resolved: Vec<(usize, V2PackageMetadata)> = {
1292                let connection = match self.open_preindexed_connection(source_index) {
1293                    Ok(c) => c,
1294                    Err(_) => continue,
1295                };
1296                if !v2_normalized_identity_tables_present(&connection) {
1297                    continue;
1298                }
1299                let mut acc = Vec::new();
1300                for (idx, pkg) in installed.iter().enumerate() {
1301                    if pkg.correlated.is_some() {
1302                        continue;
1303                    }
1304                    // MSIX entries belong to the PFN-only path; skipping
1305                    // here mirrors `correlate_installed_package`'s
1306                    // hard-block on MSIX (their `ms-resource:` placeholder
1307                    // names normalize to garbage anyway).
1308                    if pkg.local_id.starts_with("MSIX\\") {
1309                        continue;
1310                    }
1311                    let Some(publisher) = pkg.publisher.as_deref() else {
1312                        continue;
1313                    };
1314                    let normalized_name = name_normalization::normalize_name(&pkg.name).name;
1315                    if normalized_name.is_empty() {
1316                        continue;
1317                    }
1318                    let normalized_publisher = name_normalization::normalize_publisher(publisher);
1319                    if normalized_publisher.is_empty() {
1320                        continue;
1321                    }
1322                    let Some(rowid) =
1323                        lookup_unique_normalized_identity(&connection, &normalized_name, &normalized_publisher)?
1324                    else {
1325                        continue;
1326                    };
1327                    let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1328                        continue;
1329                    };
1330                    acc.push((idx, meta));
1331                }
1332                acc
1333            };
1334
1335            let source = self.source_clone(source_index);
1336            for (idx, meta) in resolved {
1337                let (canonical_installed, anchored_latest) =
1338                    match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1339                        Ok((entries, _)) => (
1340                            map_arp_version_to_catalog(&entries, &installed[idx].installed_version),
1341                            latest_arp_anchored_version(&entries),
1342                        ),
1343                        Err(_) => (None, None),
1344                    };
1345                let available_version = match (&canonical_installed, anchored_latest) {
1346                    (Some(_), Some(anchored)) => anchored,
1347                    _ => meta.latest_version,
1348                };
1349                let installed_pkg = &mut installed[idx];
1350                installed_pkg.correlated = Some(SearchMatch {
1351                    source_name: source.name.clone(),
1352                    source_kind: source.kind,
1353                    id: meta.id,
1354                    name: meta.name,
1355                    moniker: meta.moniker,
1356                    version: Some(available_version),
1357                    channel: None,
1358                    match_criteria: Some("NormalizedNameAndPublisher".to_owned()),
1359                });
1360                if let Some(version) = canonical_installed {
1361                    installed_pkg.installed_version = version;
1362                    installed_pkg.installed_version_canonical = true;
1363                }
1364            }
1365        }
1366
1367        Ok(Vec::new())
1368    }
1369
1370    /// Runs after both correlation paths to enrich already-correlated rows
1371    /// with information that the identity path applies inline but that
1372    /// name-based correlation skips:
1373    ///   • The MSIX-placeholder name fix — when an MSIX entry's installed
1374    ///     name is an unresolved Windows resource string (`ms-resource:...`)
1375    ///     winget renders the catalog's `PackageName` instead. We do the
1376    ///     same so display-name comparisons match.
1377    ///   • The aMiV/aMaV version remap — `JetBrains.Rider` has no
1378    ///     ProductCode/PFN/UpgradeCode so it correlates by name; without
1379    ///     this pass its installed version stays as the ARP build number
1380    ///     `253.28294.112` instead of the catalog Version `2025.3.0.1`.
1381    fn enrich_correlated_via_index(
1382        &mut self,
1383        installed: &mut [InstalledPackage],
1384        requested_source: Option<&str>,
1385    ) -> Result<Vec<String>> {
1386        // Cheap pass first — no SQL required.
1387        for package in installed.iter_mut() {
1388            apply_msix_resource_string_name_fix(package);
1389        }
1390
1391        // Expensive pass — load versionData for rows whose installed_version
1392        // wasn't remapped earlier.
1393        let source_indices: Vec<usize> = self
1394            .store
1395            .sources
1396            .iter()
1397            .enumerate()
1398            .filter(|(_, source)| {
1399                source.kind == SourceKind::PreIndexed
1400                    && match requested_source {
1401                        Some(name) => source.name.eq_ignore_ascii_case(name),
1402                        None => true,
1403                    }
1404            })
1405            .map(|(index, _)| index)
1406            .collect();
1407
1408        for source_index in source_indices {
1409            let needs: Vec<(usize, V2PackageMetadata)> = {
1410                let connection = match self.open_preindexed_connection(source_index) {
1411                    Ok(c) => c,
1412                    Err(_) => continue,
1413                };
1414                if !v2_identity_tables_present(&connection) {
1415                    continue;
1416                }
1417                let mut acc = Vec::new();
1418                for (idx, pkg) in installed.iter().enumerate() {
1419                    let Some(candidate) = pkg.correlated.as_ref() else {
1420                        continue;
1421                    };
1422                    // Stay within the source the row was correlated to —
1423                    // looking up `JetBrains.Rider` in a different catalog
1424                    // would either miss or pull the wrong package's range.
1425                    if !candidate
1426                        .source_name
1427                        .eq_ignore_ascii_case(&self.store.sources[source_index].name)
1428                    {
1429                        continue;
1430                    }
1431                    let Some(meta) = lookup_v2_metadata_by_id(&connection, &candidate.id)? else {
1432                        continue;
1433                    };
1434                    acc.push((idx, meta));
1435                }
1436                acc
1437            };
1438
1439            let source = self.source_clone(source_index);
1440            for (idx, meta) in needs {
1441                // Load versionData once: we may need it for both the
1442                // aMiV/aMaV remap (only if installed_version isn't already
1443                // canonical) and to locate the latest manifest for the
1444                // RequireExplicitUpgrade flag.
1445                let entries = match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1446                    Ok((entries, _)) => entries,
1447                    Err(_) => continue,
1448                };
1449
1450                if !installed[idx].installed_version_canonical {
1451                    let canonical_installed = map_arp_version_to_catalog(&entries, &installed[idx].installed_version);
1452                    let anchored_latest = latest_arp_anchored_version(&entries);
1453                    if let Some(canonical) = canonical_installed {
1454                        installed[idx].installed_version = canonical;
1455                        installed[idx].installed_version_canonical = true;
1456                        if let (Some(correlated), Some(anchored)) =
1457                            (installed[idx].correlated.as_mut(), anchored_latest)
1458                        {
1459                            correlated.version = Some(anchored);
1460                        }
1461                    }
1462                }
1463
1464                // RequireExplicitUpgrade is set on the installer manifest
1465                // for the latest catalog version. winget hides these from
1466                // bulk `upgrade` (Edge, Steam, Discord and others). We
1467                // peek at the first versionData entry (latest by sort
1468                // order) and read its manifest to mirror that filtering.
1469                if let Some(latest) = entries.first() {
1470                    let bytes_result = self.get_cached_source_file(
1471                        "V2_M",
1472                        &source,
1473                        &latest.manifest_relative_path,
1474                        Some(latest.manifest_hash.as_str()),
1475                    );
1476                    if let Ok(bytes) = bytes_result
1477                        && let Ok(manifest) = parse_yaml_manifest(&bytes.bytes)
1478                    {
1479                        installed[idx].correlated_requires_explicit_upgrade = manifest.require_explicit_upgrade;
1480                        installed[idx].correlated_lacks_compatible_installer =
1481                            !manifest_has_compatible_installer(&manifest);
1482                    }
1483                }
1484            }
1485        }
1486
1487        Ok(Vec::new())
1488    }
1489
1490    pub fn search_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1491        let (located, warnings) = self.find_single_match_with_semantics(query, SearchSemantics::Many)?;
1492        let versions = self.versions_for_match(&located, query)?;
1493        Ok(VersionsResult {
1494            package: located.display,
1495            versions,
1496            warnings,
1497        })
1498    }
1499
1500    pub fn show_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1501        let (located, warnings) = self.find_single_match(query)?;
1502        let versions = self.versions_for_match(&located, query)?;
1503        Ok(VersionsResult {
1504            package: located.display,
1505            versions,
1506            warnings,
1507        })
1508    }
1509
1510    pub fn show(&mut self, query: &PackageQuery) -> Result<ShowResult> {
1511        let (located, warnings) = self.find_single_match(query)?;
1512        let (manifest, manifest_documents, cached_files) = self.manifest_for_match(&located, query)?;
1513        let selected_installer = select_installer(&manifest.installers, query);
1514
1515        Ok(ShowResult {
1516            package: located.display,
1517            manifest,
1518            selected_installer,
1519            cached_files,
1520            warnings,
1521            manifest_documents,
1522        })
1523    }
1524
1525    pub fn warm_cache(&mut self, query: &PackageQuery) -> Result<CacheWarmResult> {
1526        let (located, warnings) = self.find_single_match(query)?;
1527        let (_, _, cached_files) = self.manifest_for_match(&located, query)?;
1528
1529        Ok(CacheWarmResult {
1530            package: located.display,
1531            cached_files,
1532            warnings,
1533        })
1534    }
1535
1536    // ── Pin management ──
1537
1538    pub fn list_pins(&self, source_id: Option<&str>) -> Result<Vec<PinRecord>> {
1539        let db_path = pins_db_path(&self.app_root);
1540        if !db_path.exists() {
1541            return Ok(Vec::new());
1542        }
1543        let conn = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
1544        let pin_type_column = match resolve_pin_type_column(&conn)? {
1545            Some(column) => column,
1546            None => return Ok(Vec::new()),
1547        };
1548        let sql = if source_id.is_some() {
1549            format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin WHERE source_id = ?1")
1550        } else {
1551            format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin")
1552        };
1553        let mut stmt = conn.prepare(&sql)?;
1554        let rows = if let Some(source_id) = source_id {
1555            stmt.query_map([source_id], |row| {
1556                let pin_type_int: i64 = row.get(3)?;
1557                Ok(PinRecord {
1558                    package_id: row.get(0)?,
1559                    version: row.get(1)?,
1560                    source_id: row.get(2)?,
1561                    pin_type: decode_pin_type(pin_type_int),
1562                })
1563            })?
1564            .filter_map(|r| r.ok())
1565            .collect()
1566        } else {
1567            stmt.query_map([], |row| {
1568                let pin_type_int: i64 = row.get(3)?;
1569                Ok(PinRecord {
1570                    package_id: row.get(0)?,
1571                    version: row.get(1)?,
1572                    source_id: row.get(2)?,
1573                    pin_type: decode_pin_type(pin_type_int),
1574                })
1575            })?
1576            .filter_map(|r| r.ok())
1577            .collect()
1578        };
1579        Ok(rows)
1580    }
1581
1582    pub fn add_pin(&self, package_id: &str, version: &str, source_id: &str, pin_type: PinType) -> Result<()> {
1583        let db_path = pins_db_path(&self.app_root);
1584        let conn = Connection::open(&db_path)?;
1585        conn.execute_batch(
1586            "CREATE TABLE IF NOT EXISTS pin (
1587                package_id TEXT NOT NULL,
1588                version TEXT NOT NULL DEFAULT '*',
1589                source_id TEXT NOT NULL DEFAULT '',
1590                type INTEGER NOT NULL DEFAULT 0,
1591                PRIMARY KEY (package_id, source_id)
1592            )",
1593        )?;
1594        let pin_type_column = resolve_pin_type_column(&conn)?.unwrap_or("type");
1595        let type_int: i64 = match pin_type {
1596            PinType::Pinning => 2,
1597            PinType::Blocking => 4,
1598            PinType::Gating => 3,
1599        };
1600        conn.execute(
1601            &format!(
1602                "INSERT OR REPLACE INTO pin (package_id, version, source_id, {pin_type_column}) VALUES (?1, ?2, ?3, ?4)"
1603            ),
1604            rusqlite::params![package_id, version, source_id, type_int],
1605        )?;
1606        Ok(())
1607    }
1608
1609    pub fn remove_pin(&self, package_id: &str, source_id: Option<&str>) -> Result<bool> {
1610        let db_path = pins_db_path(&self.app_root);
1611        if !db_path.exists() {
1612            return Ok(false);
1613        }
1614        let conn = Connection::open(&db_path)?;
1615        let count = if let Some(source_id) = source_id {
1616            conn.execute(
1617                "DELETE FROM pin WHERE package_id = ?1 AND source_id = ?2",
1618                rusqlite::params![package_id, source_id],
1619            )?
1620        } else {
1621            conn.execute("DELETE FROM pin WHERE package_id = ?1", rusqlite::params![package_id])?
1622        };
1623        Ok(count > 0)
1624    }
1625
1626    pub fn reset_pins(&self, source_id: Option<&str>) -> Result<()> {
1627        let db_path = pins_db_path(&self.app_root);
1628        if !db_path.exists() {
1629            return Ok(());
1630        }
1631
1632        if let Some(source_id) = source_id {
1633            let conn = Connection::open(&db_path)?;
1634            conn.execute("DELETE FROM pin WHERE source_id = ?1", rusqlite::params![source_id])?;
1635        } else {
1636            fs::remove_file(&db_path)?;
1637        }
1638        Ok(())
1639    }
1640
1641    fn save_store(&self) -> Result<()> {
1642        save_store(&self.app_root, &self.store)
1643    }
1644
1645    fn source_state_dir(&self, source: &SourceRecord) -> PathBuf {
1646        source_state_dir(&self.app_root, source)
1647    }
1648
1649    // ── Install / download ──
1650
1651    pub fn download_installer(&mut self, query: &PackageQuery, download_dir: &Path) -> Result<(Manifest, PathBuf)> {
1652        self.download_installer_for_request(&InstallRequest::new(query.clone()), download_dir)
1653    }
1654
1655    pub fn download_installer_for_request(
1656        &mut self,
1657        request: &InstallRequest,
1658        download_dir: &Path,
1659    ) -> Result<(Manifest, PathBuf)> {
1660        let manifest = self.resolve_manifest_for_install(request)?;
1661        let installer = select_installer(&manifest.installers, &request.query)
1662            .ok_or_else(|| anyhow!("No applicable installer found for the current system"))?;
1663        let url = installer
1664            .url
1665            .as_deref()
1666            .ok_or_else(|| anyhow!("Installer has no URL"))?;
1667
1668        fs::create_dir_all(download_dir)?;
1669        let filename = request.rename.as_deref().unwrap_or_else(|| {
1670            url.rsplit('/')
1671                .next()
1672                .unwrap_or("installer")
1673                .split('?')
1674                .next()
1675                .unwrap_or("installer")
1676        });
1677        let dest = download_dir.join(filename);
1678
1679        let response = self.client.get(url).send().context("failed to download installer")?;
1680        if !response.status().is_success() {
1681            bail!("Download failed: HTTP {}", response.status());
1682        }
1683        let bytes = response.bytes()?;
1684        fs::write(&dest, &bytes)?;
1685
1686        if let Err(error) = verify_installer_hash(installer.sha256.as_deref(), &bytes, request.ignore_security_hash) {
1687            let _ = fs::remove_file(&dest);
1688            return Err(error);
1689        }
1690
1691        Ok((manifest, dest))
1692    }
1693
1694    pub fn install(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
1695        let mut request = InstallRequest::new(query.clone());
1696        request.mode = if silent {
1697            InstallerMode::Silent
1698        } else {
1699            InstallerMode::SilentWithProgress
1700        };
1701        self.install_request(&request)
1702    }
1703
1704    pub fn install_with_mode(&mut self, query: &PackageQuery, mode: InstallerMode) -> Result<InstallResult> {
1705        let mut request = InstallRequest::new(query.clone());
1706        request.mode = mode;
1707        self.install_request(&request)
1708    }
1709
1710    pub fn install_request(&mut self, request: &InstallRequest) -> Result<InstallResult> {
1711        let manifest = self.resolve_manifest_for_install(request)?;
1712        if !package_actions_supported() {
1713            let installer_type = select_installer(&manifest.installers, &request.query)
1714                .and_then(|installer| installer.installer_type)
1715                .unwrap_or_else(|| "install".to_owned())
1716                .to_lowercase();
1717            return Ok(unsupported_action_result(
1718                manifest.id.clone(),
1719                manifest.version.clone(),
1720                installer_type,
1721                INSTALL_UNSUPPORTED_WARNING,
1722            ));
1723        }
1724
1725        let existing_match = self.find_installed_package_for_install(request, &manifest)?;
1726        if let Some(no_op_result) = Self::create_install_no_op_result(request, &manifest, existing_match.as_ref()) {
1727            return Ok(no_op_result);
1728        }
1729
1730        Self::ensure_package_agreements_accepted(&manifest, request)?;
1731        self.install_dependencies(&manifest, request, &mut HashSet::new())?;
1732
1733        if request.dependencies_only {
1734            return Ok(InstallResult {
1735                package_id: manifest.id.clone(),
1736                version: manifest.version.clone(),
1737                installer_path: PathBuf::new(),
1738                installer_type: "dependencies".to_owned(),
1739                exit_code: 0,
1740                success: true,
1741                no_op: false,
1742                warnings: Vec::new(),
1743            });
1744        }
1745
1746        let installer = select_installer(&manifest.installers, &request.query)
1747            .ok_or_else(|| anyhow!("No applicable installer found"))?;
1748
1749        if request.uninstall_previous {
1750            let mut uninstall_request = UninstallRequest::new(PackageQuery {
1751                id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
1752                query: request.query.query.clone().or_else(|| Some(manifest.id.clone())),
1753                source: request.query.source.clone(),
1754                exact: true,
1755                version: request.query.version.clone(),
1756                ..PackageQuery::default()
1757            });
1758            uninstall_request.product_code = installer.product_code.clone();
1759            uninstall_request.mode = InstallerMode::Silent;
1760            uninstall_request.all_versions = true;
1761            uninstall_request.force = true;
1762            let _ = self.uninstall_request(&uninstall_request);
1763        }
1764
1765        let temp_dir = std::env::temp_dir().join("pinget-install");
1766        let (_, installer_path) = self.download_installer_for_request(request, &temp_dir)?;
1767
1768        let installer_type = installer.installer_type.as_deref().unwrap_or("exe").to_lowercase();
1769
1770        let exit_code = dispatch_installer(&installer_path, &installer_type, request, &manifest, &installer)?;
1771
1772        Ok(InstallResult {
1773            package_id: manifest.id.clone(),
1774            version: manifest.version.clone(),
1775            installer_path,
1776            installer_type,
1777            exit_code,
1778            success: exit_code == 0,
1779            no_op: false,
1780            warnings: Vec::new(),
1781        })
1782    }
1783
1784    fn find_installed_package_for_install(
1785        &mut self,
1786        request: &InstallRequest,
1787        manifest: &Manifest,
1788    ) -> Result<Option<ListMatch>> {
1789        let installed_matches = self.list(&ListQuery {
1790            query: request.query.query.clone(),
1791            id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
1792            name: request.query.name.clone(),
1793            moniker: request.query.moniker.clone(),
1794            tag: None,
1795            command: None,
1796            product_code: None,
1797            version: None,
1798            source: request.query.source.clone(),
1799            count: Some(100),
1800            exact: request.query.exact || !manifest.id.trim().is_empty(),
1801            install_scope: request.query.install_scope.clone(),
1802            upgrade_only: false,
1803            include_unknown: false,
1804            include_pinned: false,
1805        })?;
1806
1807        Ok(installed_matches.matches.into_iter().find(|candidate| {
1808            candidate.id.eq_ignore_ascii_case(&manifest.id) || candidate.local_id.eq_ignore_ascii_case(&manifest.id)
1809        }))
1810    }
1811
1812    fn create_install_no_op_result(
1813        request: &InstallRequest,
1814        manifest: &Manifest,
1815        existing_match: Option<&ListMatch>,
1816    ) -> Option<InstallResult> {
1817        let existing_match = existing_match?;
1818
1819        if request.no_upgrade {
1820            return Some(InstallResult {
1821                package_id: manifest.id.clone(),
1822                version: existing_match.installed_version.clone(),
1823                installer_path: PathBuf::new(),
1824                installer_type: "install".to_owned(),
1825                exit_code: 0,
1826                success: true,
1827                no_op: true,
1828                warnings: vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()],
1829            });
1830        }
1831
1832        if !request.force
1833            && !existing_match.installed_version.trim().is_empty()
1834            && compare_version(&existing_match.installed_version, &manifest.version) != Ordering::Less
1835        {
1836            return Some(InstallResult {
1837                package_id: manifest.id.clone(),
1838                version: existing_match.installed_version.clone(),
1839                installer_path: PathBuf::new(),
1840                installer_type: "install".to_owned(),
1841                exit_code: 0,
1842                success: true,
1843                no_op: true,
1844                warnings: vec![
1845                    "Package is already installed and up to date; rerun with --force to reinstall.".to_owned(),
1846                ],
1847            });
1848        }
1849
1850        None
1851    }
1852
1853    pub fn repair(&mut self, request: &RepairRequest) -> Result<InstallResult> {
1854        if request.manifest_path.is_none() && !package_actions_supported() {
1855            let package_id = request
1856                .query
1857                .id
1858                .clone()
1859                .or_else(|| request.query.name.clone())
1860                .or_else(|| request.query.moniker.clone())
1861                .or_else(|| request.query.query.clone())
1862                .or_else(|| request.product_code.clone())
1863                .unwrap_or_else(|| "repair".to_owned());
1864            return Ok(unsupported_action_result(
1865                package_id,
1866                request.query.version.clone().unwrap_or_default(),
1867                "repair".to_owned(),
1868                REPAIR_UNSUPPORTED_WARNING,
1869            ));
1870        }
1871
1872        let mut warnings = vec![REPAIR_REINSTALL_WARNING.to_owned()];
1873        let installed_match = if request.manifest_path.is_some() {
1874            None
1875        } else {
1876            let installed = self.list(&Self::create_repair_list_query(request))?;
1877            warnings.extend(installed.warnings);
1878            if installed.matches.is_empty() {
1879                bail!("No installed package matched the supplied repair query.");
1880            }
1881            if installed.matches.len() > 1 {
1882                bail!("Multiple installed packages matched the supplied repair query.");
1883            }
1884            installed.matches.into_iter().next()
1885        };
1886
1887        let mut result =
1888            self.install_request(&Self::create_repair_install_request(request, installed_match.as_ref()))?;
1889        warnings.extend(result.warnings);
1890        result.warnings = warnings;
1891        Ok(result)
1892    }
1893
1894    pub fn uninstall(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
1895        let mut request = UninstallRequest::new(query.clone());
1896        request.mode = if silent {
1897            InstallerMode::Silent
1898        } else {
1899            InstallerMode::SilentWithProgress
1900        };
1901        self.uninstall_request(&request)
1902    }
1903
1904    pub fn uninstall_request(&mut self, request: &UninstallRequest) -> Result<InstallResult> {
1905        if !package_actions_supported() {
1906            let (package_id, version) = if let Some(path) = &request.manifest_path {
1907                let manifest = Self::load_manifest_from_path(path)?;
1908                (manifest.id, request.query.version.clone().unwrap_or(manifest.version))
1909            } else {
1910                (
1911                    request
1912                        .query
1913                        .id
1914                        .clone()
1915                        .or_else(|| request.query.query.clone())
1916                        .or_else(|| request.query.name.clone())
1917                        .or_else(|| request.query.moniker.clone())
1918                        .or_else(|| request.product_code.clone())
1919                        .unwrap_or_else(|| "unknown".to_owned()),
1920                    request.query.version.clone().unwrap_or_default(),
1921                )
1922            };
1923
1924            return Ok(unsupported_action_result(
1925                package_id,
1926                version,
1927                "uninstall",
1928                UNINSTALL_UNSUPPORTED_WARNING,
1929            ));
1930        }
1931
1932        let matches = self.resolve_uninstall_matches(request)?;
1933        let mut exit_code = 0;
1934
1935        for installed in &matches {
1936            exit_code = uninstall_package(installed, request)?;
1937            if exit_code != 0 && !request.all_versions {
1938                break;
1939            }
1940        }
1941
1942        let installed = matches
1943            .first()
1944            .ok_or_else(|| anyhow!("No installed package found matching the query"))?;
1945
1946        Ok(InstallResult {
1947            package_id: installed.id.clone(),
1948            version: installed.installed_version.clone(),
1949            installer_path: PathBuf::new(),
1950            installer_type: "uninstall".to_owned(),
1951            exit_code,
1952            success: exit_code == 0,
1953            no_op: false,
1954            warnings: Vec::new(),
1955        })
1956    }
1957
1958    fn resolve_manifest_for_install(&mut self, request: &InstallRequest) -> Result<Manifest> {
1959        if let Some(path) = &request.manifest_path {
1960            return Self::load_manifest_from_path(path);
1961        }
1962
1963        let (located, _warnings) = self.find_single_match(&request.query)?;
1964        let (manifest, _, _cached_files) = self.manifest_for_match(&located, &request.query)?;
1965        Ok(manifest)
1966    }
1967
1968    fn ensure_package_agreements_accepted(manifest: &Manifest, request: &InstallRequest) -> Result<()> {
1969        if !request.accept_package_agreements && !manifest.agreements.is_empty() {
1970            bail!("Package agreements are present; rerun with --accept-package-agreements to continue.");
1971        }
1972        Ok(())
1973    }
1974
1975    fn install_dependencies(
1976        &mut self,
1977        manifest: &Manifest,
1978        request: &InstallRequest,
1979        visited: &mut HashSet<String>,
1980    ) -> Result<()> {
1981        if request.skip_dependencies {
1982            return Ok(());
1983        }
1984
1985        let dependency_ids = manifest
1986            .package_dependencies
1987            .iter()
1988            .chain(
1989                manifest
1990                    .installers
1991                    .iter()
1992                    .flat_map(|installer| installer.package_dependencies.iter()),
1993            )
1994            .filter(|value| !value.trim().is_empty())
1995            .cloned()
1996            .collect::<BTreeSet<_>>();
1997
1998        for dependency_id in dependency_ids {
1999            if !visited.insert(dependency_id.clone()) {
2000                continue;
2001            }
2002
2003            let dependency_request = Self::create_dependency_install_request(dependency_id, request);
2004            self.install_request(&dependency_request)?;
2005        }
2006
2007        Ok(())
2008    }
2009
2010    fn create_dependency_install_request(dependency_id: String, request: &InstallRequest) -> InstallRequest {
2011        let mut dependency_request = InstallRequest::new(PackageQuery {
2012            id: Some(dependency_id),
2013            source: request
2014                .dependency_source
2015                .clone()
2016                .or_else(|| request.query.source.clone()),
2017            exact: true,
2018            ..PackageQuery::default()
2019        });
2020        dependency_request.mode = match request.mode {
2021            InstallerMode::Interactive => InstallerMode::Interactive,
2022            InstallerMode::SilentWithProgress | InstallerMode::Silent => InstallerMode::Silent,
2023        };
2024        dependency_request.accept_package_agreements = request.accept_package_agreements;
2025        dependency_request.no_upgrade = true;
2026        dependency_request.ignore_security_hash = request.ignore_security_hash;
2027        dependency_request.dependency_source = request.dependency_source.clone();
2028        dependency_request
2029    }
2030
2031    fn create_repair_list_query(request: &RepairRequest) -> ListQuery {
2032        ListQuery {
2033            query: request.query.query.clone(),
2034            id: request.query.id.clone(),
2035            name: request.query.name.clone(),
2036            moniker: request.query.moniker.clone(),
2037            product_code: request.product_code.clone(),
2038            version: request.query.version.clone(),
2039            source: request.query.source.clone(),
2040            count: Some(100),
2041            exact: request.query.exact,
2042            install_scope: request.query.install_scope.clone(),
2043            ..ListQuery::default()
2044        }
2045    }
2046
2047    fn create_repair_install_request(request: &RepairRequest, installed_match: Option<&ListMatch>) -> InstallRequest {
2048        let mut query = request.query.clone();
2049        query.query = if installed_match.is_none() {
2050            request.query.query.clone()
2051        } else {
2052            None
2053        };
2054        query.id = installed_match
2055            .map(|item| item.id.clone())
2056            .or_else(|| request.query.id.clone());
2057        query.name = if installed_match.is_none() {
2058            request.query.name.clone()
2059        } else {
2060            None
2061        };
2062        query.moniker = if installed_match.is_none() {
2063            request.query.moniker.clone()
2064        } else {
2065            None
2066        };
2067        query.source = request
2068            .query
2069            .source
2070            .clone()
2071            .or_else(|| installed_match.and_then(|item| item.source_name.clone()));
2072        query.exact = true;
2073        query.version = request
2074            .query
2075            .version
2076            .clone()
2077            .or_else(|| installed_match.map(|item| item.installed_version.clone()));
2078
2079        InstallRequest {
2080            query,
2081            manifest_path: request.manifest_path.clone(),
2082            mode: request.mode,
2083            log_path: request.log_path.clone(),
2084            custom: None,
2085            override_args: None,
2086            install_location: None,
2087            skip_dependencies: false,
2088            dependencies_only: false,
2089            accept_package_agreements: request.accept_package_agreements,
2090            force: true,
2091            rename: None,
2092            uninstall_previous: false,
2093            ignore_security_hash: request.ignore_security_hash,
2094            dependency_source: None,
2095            no_upgrade: false,
2096        }
2097    }
2098
2099    fn resolve_uninstall_matches(&mut self, request: &UninstallRequest) -> Result<Vec<ListMatch>> {
2100        let mut effective_query = request.query.clone();
2101        let mut effective_product_code = request.product_code.clone();
2102
2103        if let Some(path) = &request.manifest_path {
2104            let manifest = Self::load_manifest_from_path(path)?;
2105            effective_query = PackageQuery {
2106                query: Some(manifest.id.clone()),
2107                id: Some(manifest.id.clone()),
2108                name: Some(manifest.name.clone()),
2109                exact: true,
2110                version: effective_query.version.clone().or(Some(manifest.version)),
2111                ..PackageQuery::default()
2112            };
2113            effective_product_code = effective_product_code.or_else(|| {
2114                manifest
2115                    .installers
2116                    .iter()
2117                    .find_map(|installer| installer.product_code.clone())
2118            });
2119        }
2120
2121        let list_query = ListQuery {
2122            query: effective_query.query.clone(),
2123            id: effective_query.id.clone(),
2124            name: effective_query.name.clone(),
2125            moniker: effective_query.moniker.clone(),
2126            tag: None,
2127            command: None,
2128            product_code: effective_product_code,
2129            version: effective_query.version.clone(),
2130            source: effective_query.source.clone(),
2131            count: if request.all_versions { None } else { Some(100) },
2132            exact: effective_query.exact,
2133            install_scope: effective_query.install_scope,
2134            upgrade_only: false,
2135            include_unknown: false,
2136            include_pinned: false,
2137        };
2138
2139        let list_result = self.list(&list_query)?;
2140        if list_result.matches.is_empty() {
2141            bail!("No installed package found matching the query");
2142        }
2143        if !request.all_versions && list_result.matches.len() > 1 && !request.force {
2144            bail!("Multiple installed packages matched the query; refine the query or use --all-versions.");
2145        }
2146
2147        Ok(if request.all_versions {
2148            list_result.matches
2149        } else {
2150            vec![list_result.matches[0].clone()]
2151        })
2152    }
2153
2154    fn load_manifest_from_path(manifest_path: &Path) -> Result<Manifest> {
2155        let resolved = resolve_manifest_path(manifest_path)?;
2156        let bytes =
2157            fs::read(&resolved).with_context(|| format!("failed to read manifest from {}", resolved.display()))?;
2158        parse_yaml_manifest(&bytes)
2159    }
2160
2161    fn find_single_match(&mut self, query: &PackageQuery) -> Result<(LocatedMatch, Vec<String>)> {
2162        self.find_single_match_with_semantics(query, SearchSemantics::Single)
2163    }
2164
2165    fn find_single_match_with_semantics(
2166        &mut self,
2167        query: &PackageQuery,
2168        semantics: SearchSemantics,
2169    ) -> Result<(LocatedMatch, Vec<String>)> {
2170        let (matches, warnings, _) = self.search_located(query, semantics)?;
2171
2172        if matches.is_empty() {
2173            bail!("no package matched the supplied query");
2174        }
2175
2176        if matches.len() > 1 {
2177            let choices = matches
2178                .iter()
2179                .take(10)
2180                .map(|item| {
2181                    format!(
2182                        "{} [{}] ({})",
2183                        item.display.name, item.display.id, item.display.source_name
2184                    )
2185                })
2186                .collect::<Vec<_>>()
2187                .join(", ");
2188            bail!("multiple packages matched: {choices}");
2189        }
2190
2191        Ok((matches.into_iter().next().expect("one match"), warnings))
2192    }
2193
2194    fn search_located(
2195        &mut self,
2196        query: &PackageQuery,
2197        semantics: SearchSemantics,
2198    ) -> Result<(Vec<LocatedMatch>, Vec<String>, bool)> {
2199        let indexes = self.resolve_source_indexes(query.source.as_deref())?;
2200        let mut matches = Vec::new();
2201        let mut warnings = Vec::new();
2202        let mut truncated = false;
2203
2204        for index in indexes {
2205            match self.search_source(index, query, semantics) {
2206                Ok(mut source_matches) => {
2207                    truncated |= source_matches.truncated;
2208                    matches.append(&mut source_matches.matches);
2209                }
2210                Err(_error) => warnings.push(format!(
2211                    "Failed when searching source; results will not be included: {}",
2212                    self.store.sources[index].name
2213                )),
2214            }
2215        }
2216
2217        if semantics == SearchSemantics::Many {
2218            matches.sort_by(|left, right| {
2219                search_match_sort_score(&right.display, query).cmp(&search_match_sort_score(&left.display, query))
2220            });
2221            let limit = max_results(query);
2222            if matches.len() > limit {
2223                truncated = true;
2224                matches.truncate(limit);
2225            }
2226        }
2227
2228        Ok((matches, warnings, truncated))
2229    }
2230
2231    fn search_source(
2232        &mut self,
2233        source_index: usize,
2234        query: &PackageQuery,
2235        semantics: SearchSemantics,
2236    ) -> Result<SearchSourceMatches> {
2237        match self.store.sources[source_index].kind {
2238            SourceKind::PreIndexed => self.search_preindexed(source_index, query, semantics),
2239            SourceKind::Rest => self.search_rest(source_index, query, semantics),
2240        }
2241    }
2242
2243    fn versions_for_match(&mut self, located: &LocatedMatch, _query: &PackageQuery) -> Result<Vec<VersionKey>> {
2244        match &located.locator {
2245            MatchLocator::PreIndexedV1 { package_rowid } => {
2246                let source = self.source_clone(located.source_index);
2247                let connection = self.open_preindexed_connection(located.source_index)?;
2248                let versions = query_v1_versions(&connection, *package_rowid)?;
2249                let mut keys = versions
2250                    .into_iter()
2251                    .map(|row| VersionKey {
2252                        version: row.version,
2253                        channel: row.channel,
2254                    })
2255                    .collect::<Vec<_>>();
2256                sort_versions_desc(&mut keys);
2257                if keys.is_empty() {
2258                    bail!("no versions found for {} in {}", located.display.id, source.name);
2259                }
2260                Ok(keys)
2261            }
2262            MatchLocator::PreIndexedV2 {
2263                package_rowid,
2264                package_hash,
2265            } => {
2266                let source = self.source_clone(located.source_index);
2267                let (entries, _) = self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2268                let mut keys = entries
2269                    .into_iter()
2270                    .map(|entry| VersionKey {
2271                        version: entry.version,
2272                        channel: String::new(),
2273                    })
2274                    .collect::<Vec<_>>();
2275                sort_versions_desc(&mut keys);
2276                Ok(keys)
2277            }
2278            MatchLocator::Rest { versions, .. } => {
2279                let mut keys = versions.clone();
2280                sort_versions_desc(&mut keys);
2281                Ok(keys)
2282            }
2283        }
2284    }
2285
2286    fn manifest_for_match(
2287        &mut self,
2288        located: &LocatedMatch,
2289        query: &PackageQuery,
2290    ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2291        match &located.locator {
2292            MatchLocator::PreIndexedV1 { package_rowid } => {
2293                let source = self.source_clone(located.source_index);
2294                let connection = self.open_preindexed_connection(located.source_index)?;
2295                let versions = query_v1_versions(&connection, *package_rowid)?;
2296                let selected = select_v1_version(&versions, query.version.as_deref(), query.channel.as_deref())?;
2297                let relative_path = resolve_v1_relative_path(&connection, selected.pathpart_id)?;
2298                let bytes =
2299                    self.get_cached_source_file("V1_M", &source, &relative_path, selected.manifest_hash.as_deref())?;
2300                let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&bytes.bytes)?;
2301                manifest.version = selected.version.clone();
2302                manifest.channel = selected.channel;
2303                Ok((manifest, manifest_documents, vec![bytes.path]))
2304            }
2305            MatchLocator::PreIndexedV2 {
2306                package_rowid,
2307                package_hash,
2308            } => {
2309                let source = self.source_clone(located.source_index);
2310                let (entries, version_data_file) =
2311                    self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2312                let selected = select_v2_version(&entries, query.version.as_deref())?;
2313                let manifest_bytes = self.get_cached_source_file(
2314                    "V2_M",
2315                    &source,
2316                    &selected.manifest_relative_path,
2317                    Some(selected.manifest_hash.as_str()),
2318                )?;
2319                let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&manifest_bytes.bytes)?;
2320                manifest.version = selected.version;
2321                Ok((
2322                    manifest,
2323                    manifest_documents,
2324                    vec![version_data_file, manifest_bytes.path],
2325                ))
2326            }
2327            MatchLocator::Rest { package_id, versions } => {
2328                let source = self.source_clone(located.source_index);
2329                let selected = select_rest_version(versions, query.version.as_deref(), query.channel.as_deref())?;
2330                let (bytes, cache_path) =
2331                    self.get_or_fetch_rest_manifest(&source, package_id, &selected.version, &selected.channel)?;
2332                let (manifest, manifest_documents) =
2333                    parse_rest_manifest(&bytes, package_id, &selected.version, &selected.channel)?;
2334                Ok((manifest, manifest_documents, vec![cache_path]))
2335            }
2336        }
2337    }
2338
2339    fn search_preindexed(
2340        &mut self,
2341        source_index: usize,
2342        query: &PackageQuery,
2343        semantics: SearchSemantics,
2344    ) -> Result<SearchSourceMatches> {
2345        let connection = self.open_preindexed_connection(source_index)?;
2346        let source = self.source_clone(source_index);
2347
2348        match query_v2_matches(&connection, query, semantics) {
2349            Ok((rows, truncated)) => Ok(SearchSourceMatches {
2350                truncated,
2351                matches: rows
2352                    .into_iter()
2353                    .map(|row| LocatedMatch {
2354                        display: SearchMatch {
2355                            source_name: source.name.clone(),
2356                            source_kind: source.kind,
2357                            id: row.id.clone(),
2358                            name: row.name.clone(),
2359                            moniker: row.moniker.clone(),
2360                            version: Some(row.version.clone()),
2361                            channel: None,
2362                            match_criteria: row.match_criteria.clone(),
2363                        },
2364                        source_index,
2365                        locator: MatchLocator::PreIndexedV2 {
2366                            package_rowid: row.package_rowid,
2367                            package_hash: row.package_hash,
2368                        },
2369                    })
2370                    .collect(),
2371            }),
2372            Err(error) if can_fallback_to_v1(&error) => {
2373                let (rows, truncated) = query_v1_matches(&connection, query, semantics)?;
2374                let grouped = group_v1_rows(rows);
2375
2376                Ok(SearchSourceMatches {
2377                    truncated,
2378                    matches: grouped
2379                        .into_iter()
2380                        .map(|row| LocatedMatch {
2381                            display: SearchMatch {
2382                                source_name: source.name.clone(),
2383                                source_kind: source.kind,
2384                                id: row.id.clone(),
2385                                name: row.name.clone(),
2386                                moniker: row.moniker.clone(),
2387                                version: Some(row.version.clone()),
2388                                channel: Some(row.channel.clone()),
2389                                match_criteria: row.match_criteria.clone(),
2390                            },
2391                            source_index,
2392                            locator: MatchLocator::PreIndexedV1 {
2393                                package_rowid: row.package_rowid,
2394                            },
2395                        })
2396                        .collect(),
2397                })
2398            }
2399            Err(error) => Err(error),
2400        }
2401    }
2402
2403    fn search_rest(
2404        &mut self,
2405        source_index: usize,
2406        query: &PackageQuery,
2407        semantics: SearchSemantics,
2408    ) -> Result<SearchSourceMatches> {
2409        let source = self.source_clone(source_index);
2410        let info = self.load_rest_information(source_index)?;
2411        let contract = choose_contract(&info.server_supported_versions)
2412            .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
2413
2414        let url = format!("{}/manifestSearch", source.arg.trim_end_matches('/'));
2415        let body = build_rest_search_body(query, &info, semantics)?;
2416        let response = self
2417            .client
2418            .post(url)
2419            .header("Version", contract)
2420            .json(&body)
2421            .send()
2422            .context("REST search request failed")?
2423            .error_for_status()
2424            .context("REST search request returned an error")?;
2425        let json = response
2426            .json::<JsonValue>()
2427            .context("failed to parse REST search response")?;
2428        let data = json
2429            .get("Data")
2430            .and_then(JsonValue::as_array)
2431            .cloned()
2432            .unwrap_or_default();
2433        let max_results = source_fetch_results(query, semantics);
2434
2435        let mut results = Vec::new();
2436        for item in data {
2437            let package_id = json_string(&item, "PackageIdentifier")
2438                .ok_or_else(|| anyhow!("REST search result missing PackageIdentifier"))?;
2439            let package_name =
2440                json_string(&item, "PackageName").ok_or_else(|| anyhow!("REST search result missing PackageName"))?;
2441            let mut versions = parse_rest_versions(&item)?;
2442            sort_versions_desc(&mut versions);
2443            let latest = versions
2444                .first()
2445                .cloned()
2446                .ok_or_else(|| anyhow!("REST search result had no versions"))?;
2447
2448            results.push(LocatedMatch {
2449                display: SearchMatch {
2450                    source_name: source.name.clone(),
2451                    source_kind: source.kind,
2452                    id: package_id.clone(),
2453                    name: package_name,
2454                    moniker: json_string(&item, "Moniker"),
2455                    version: Some(latest.version.clone()),
2456                    channel: if latest.channel.is_empty() {
2457                        None
2458                    } else {
2459                        Some(latest.channel.clone())
2460                    },
2461                    match_criteria: rest_match_criteria(&item, query, semantics),
2462                },
2463                source_index,
2464                locator: MatchLocator::Rest { package_id, versions },
2465            });
2466        }
2467
2468        Ok(SearchSourceMatches {
2469            truncated: results.len() >= max_results,
2470            matches: results,
2471        })
2472    }
2473
2474    fn open_preindexed_connection(&mut self, source_index: usize) -> Result<Connection> {
2475        let source = self.source_clone(source_index);
2476        let index_path = preindexed_index_path(&self.app_root, &source);
2477        if !index_path.exists() {
2478            let _ = self.update_preindexed(source_index)?;
2479            if !self.use_system_winget_sources {
2480                self.save_store()?;
2481            }
2482        }
2483
2484        Self::open_sqlite_connection(index_path).context("failed to open preindexed index")
2485    }
2486
2487    fn open_sqlite_connection(path: PathBuf) -> Result<Connection> {
2488        let path_text = path.to_string_lossy().into_owned();
2489        Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI)
2490            .with_context(|| format!("failed to open SQLite database at {path_text}"))
2491    }
2492
2493    fn update_preindexed(&mut self, source_index: usize) -> Result<String> {
2494        let source = &mut self.store.sources[source_index];
2495        let state_dir = source_state_dir(&self.app_root, source);
2496        fs::create_dir_all(&state_dir).context("failed to create source state directory")?;
2497
2498        let mut last_error = None;
2499        for candidate in PREINDEXED_CANDIDATES {
2500            let url = format!("{}/{}", source.arg.trim_end_matches('/'), candidate);
2501            match self.client.get(&url).send() {
2502                Ok(response) if response.status().is_success() => {
2503                    let header_version = response
2504                        .headers()
2505                        .get("x-ms-meta-sourceversion")
2506                        .and_then(|value| value.to_str().ok())
2507                        .map(str::to_owned);
2508                    let bytes = response.bytes().context("failed to read preindexed package bytes")?;
2509                    let payload = bytes.to_vec();
2510                    let index_bytes = extract_zip_member(&payload, "Public/index.db")
2511                        .context("preindexed package did not contain Public/index.db")?;
2512
2513                    fs::write(preindexed_package_path(&self.app_root, source), &payload)
2514                        .context("failed to persist source package")?;
2515                    fs::write(preindexed_index_path(&self.app_root, source), index_bytes)
2516                        .context("failed to persist source index")?;
2517                    source.last_update = Some(Utc::now());
2518                    source.source_version = header_version;
2519                    return Ok(format!("downloaded {}", candidate));
2520                }
2521                Ok(response) => {
2522                    last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
2523                }
2524                Err(error) => {
2525                    last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
2526                }
2527            }
2528        }
2529
2530        Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
2531    }
2532
2533    fn update_rest(&mut self, source_index: usize) -> Result<String> {
2534        let info = self.fetch_rest_information(source_index, true)?;
2535        let source = &mut self.store.sources[source_index];
2536        if !info.source_identifier.is_empty() {
2537            source.identifier = info.source_identifier.clone();
2538        }
2539        source.last_update = Some(Utc::now());
2540        source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
2541        Ok("refreshed information cache".to_owned())
2542    }
2543
2544    fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
2545        let source = self.source_clone(source_index);
2546        let cache_path = rest_information_cache_path(&self.app_root, &source);
2547
2548        if cache_path.exists() {
2549            let cache = serde_json::from_slice::<RestInfoCache>(
2550                &fs::read(&cache_path).context("failed to read REST information cache")?,
2551            )
2552            .context("failed to parse REST information cache")?;
2553            if cache.expires_at > Utc::now() {
2554                return Ok(cache.value);
2555            }
2556        }
2557
2558        self.fetch_rest_information(source_index, true)
2559    }
2560
2561    fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
2562        let source = self.source_clone(source_index);
2563        let url = format!("{}/information", source.arg.trim_end_matches('/'));
2564        let response = self
2565            .client
2566            .get(url)
2567            .send()
2568            .context("REST information request failed")?
2569            .error_for_status()
2570            .context("REST information request returned an error")?;
2571
2572        let max_age = cache_control_max_age(&response);
2573        let json = response
2574            .json::<JsonValue>()
2575            .context("failed to parse REST information response")?;
2576        let data = json
2577            .get("Data")
2578            .cloned()
2579            .ok_or_else(|| anyhow!("REST information response missing Data"))?;
2580        let info = serde_json::from_value::<RestInformation>(data)
2581            .context("failed to deserialize REST information payload")?;
2582
2583        if persist {
2584            let cache = RestInfoCache {
2585                expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
2586                value: info.clone(),
2587            };
2588            write_json(rest_information_cache_path(&self.app_root, &source), &cache)?;
2589        }
2590
2591        Ok(info)
2592    }
2593
2594    fn load_v2_version_data(
2595        &self,
2596        source: &SourceRecord,
2597        package_rowid: i64,
2598        package_hash: &str,
2599    ) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
2600        let connection = Self::open_sqlite_connection(preindexed_index_path(&self.app_root, source))
2601            .context("failed to reopen preindexed index for V2 version data")?;
2602        let package_hash = package_hash.to_ascii_lowercase();
2603        let package_id = query_optional_value(
2604            &connection,
2605            "SELECT id FROM packages WHERE rowid = ?1",
2606            vec![SqlValue::Integer(package_rowid)],
2607            |row| row_string(row, 0),
2608        )?
2609        .ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
2610        let relative_path = package_version_data_relative_path(&package_id, &package_hash);
2611        let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
2612        let yaml = decompress_mszyml(&file.bytes)?;
2613        let document =
2614            serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
2615        Ok((document.versions, file.path))
2616    }
2617
2618    fn get_cached_source_file(
2619        &self,
2620        bucket: &str,
2621        source: &SourceRecord,
2622        relative_path: &str,
2623        expected_hash: Option<&str>,
2624    ) -> Result<CachedBytes> {
2625        let normalized_relative = relative_path.replace('\\', "/");
2626        let cache_path =
2627            temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
2628
2629        if cache_path.exists() {
2630            let cached = fs::read(&cache_path).context("failed to read cached source file")?;
2631            if hash_matches(expected_hash, &cached) {
2632                return Ok(CachedBytes {
2633                    path: cache_path,
2634                    bytes: cached,
2635                });
2636            }
2637        }
2638
2639        let url = format!(
2640            "{}/{}",
2641            source.arg.trim_end_matches('/'),
2642            normalized_relative.trim_start_matches('/')
2643        );
2644        let response = self
2645            .client
2646            .get(url)
2647            .send()
2648            .context("failed to fetch source file")?
2649            .error_for_status()
2650            .context("source file request returned an error")?;
2651        let bytes = response.bytes().context("failed to read source file body")?.to_vec();
2652
2653        if let Some(hash) = expected_hash {
2654            verify_hash(hash, &bytes)?;
2655        }
2656
2657        if let Some(parent) = cache_path.parent() {
2658            fs::create_dir_all(parent).context("failed to create cache directory")?;
2659        }
2660        fs::write(&cache_path, &bytes).context("failed to write cache file")?;
2661
2662        Ok(CachedBytes {
2663            path: cache_path,
2664            bytes,
2665        })
2666    }
2667
2668    fn get_or_fetch_rest_manifest(
2669        &mut self,
2670        source: &SourceRecord,
2671        package_id: &str,
2672        version: &str,
2673        channel: &str,
2674    ) -> Result<(Vec<u8>, PathBuf)> {
2675        let cache_path = rest_manifest_cache_path_with_root(&self.app_root, source, package_id, version, channel);
2676        if cache_path.exists() {
2677            return Ok((
2678                fs::read(&cache_path).context("failed to read cached REST manifest")?,
2679                cache_path,
2680            ));
2681        }
2682
2683        let info = self.load_rest_information_by_name(&source.name)?;
2684        let contract = choose_contract(&info.server_supported_versions)
2685            .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
2686        let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
2687
2688        let mut params = vec![("Version", version.to_owned())];
2689        if !channel.is_empty() {
2690            params.push(("Channel", channel.to_owned()));
2691        }
2692        if info
2693            .required_query_parameters
2694            .iter()
2695            .any(|value| value.eq_ignore_ascii_case("Market"))
2696        {
2697            params.push(("Market", default_market()));
2698        }
2699
2700        let response = self
2701            .client
2702            .get(url)
2703            .header("Version", contract)
2704            .query(&params)
2705            .send()
2706            .context("REST manifest request failed")?
2707            .error_for_status()
2708            .context("REST manifest request returned an error")?;
2709        let bytes = response
2710            .bytes()
2711            .context("failed to read REST manifest response")?
2712            .to_vec();
2713
2714        if let Some(parent) = cache_path.parent() {
2715            fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
2716        }
2717        fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
2718
2719        Ok((bytes, cache_path))
2720    }
2721
2722    fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
2723        let index = self
2724            .store
2725            .sources
2726            .iter()
2727            .position(|source| source.name.eq_ignore_ascii_case(name))
2728            .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
2729        self.load_rest_information(index)
2730    }
2731
2732    fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
2733        if let Some(name) = source_name {
2734            let index = self
2735                .store
2736                .sources
2737                .iter()
2738                .position(|source| source.name.eq_ignore_ascii_case(name))
2739                .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
2740            return Ok(vec![index]);
2741        }
2742
2743        let mut indexes: Vec<_> = self
2744            .store
2745            .sources
2746            .iter()
2747            .enumerate()
2748            .filter(|(_, source)| !source.explicit)
2749            .collect();
2750        indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
2751            right_source
2752                .priority
2753                .cmp(&left_source.priority)
2754                .then_with(|| left_index.cmp(right_index))
2755        });
2756        Ok(indexes.into_iter().map(|(index, _)| index).collect())
2757    }
2758
2759    fn source_clone(&self, index: usize) -> SourceRecord {
2760        self.store.sources[index].clone()
2761    }
2762
2763    fn refresh_system_winget_sources(&mut self) -> Result<()> {
2764        if self.use_system_winget_sources {
2765            self.store = load_system_winget_source_store()?;
2766        }
2767        Ok(())
2768    }
2769
2770    fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
2771        self.refresh_system_winget_sources()?;
2772        let selected_names: Vec<_> = if source_name.is_none() {
2773            self.store.sources.iter().map(|source| source.name.clone()).collect()
2774        } else {
2775            self.resolve_source_indexes(source_name)?
2776                .into_iter()
2777                .map(|index| self.store.sources[index].name.clone())
2778                .collect()
2779        };
2780
2781        let detail = system_winget_update_sources(source_name)?;
2782        self.refresh_system_winget_sources()?;
2783
2784        Ok(selected_names
2785            .into_iter()
2786            .filter_map(|name| {
2787                self.store
2788                    .sources
2789                    .iter()
2790                    .find(|source| source.name.eq_ignore_ascii_case(&name))
2791                    .map(|source| SourceUpdateResult {
2792                        name: source.name.clone(),
2793                        kind: source.kind,
2794                        detail: detail.clone(),
2795                    })
2796            })
2797            .collect())
2798    }
2799}
2800
2801#[derive(Debug)]
2802struct CachedBytes {
2803    path: PathBuf,
2804    bytes: Vec<u8>,
2805}
2806
2807#[derive(Debug, Clone)]
2808struct V1SearchRow {
2809    manifest_rowid: i64,
2810    package_rowid: i64,
2811    version: String,
2812    channel: String,
2813    id: String,
2814    name: String,
2815    moniker: Option<String>,
2816    match_criteria: Option<String>,
2817}
2818
2819#[derive(Debug, Clone)]
2820struct V1VersionRow {
2821    version: String,
2822    channel: String,
2823    pathpart_id: i64,
2824    manifest_hash: Option<String>,
2825}
2826
2827#[derive(Debug, Clone)]
2828struct V2SearchRow {
2829    package_rowid: i64,
2830    package_hash: String,
2831    id: String,
2832    name: String,
2833    moniker: Option<String>,
2834    version: String,
2835    match_criteria: Option<String>,
2836}
2837
2838fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
2839    query.query.is_some()
2840        || query.id.is_some()
2841        || query.name.is_some()
2842        || query.moniker.is_some()
2843        || query.tag.is_some()
2844        || query.command.is_some()
2845        || query.source.is_some()
2846}
2847
2848fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
2849    PackageQuery {
2850        query: query.query.clone(),
2851        id: query.id.clone(),
2852        name: query.name.clone(),
2853        moniker: query.moniker.clone(),
2854        tag: query.tag.clone(),
2855        command: query.command.clone(),
2856        source: query.source.clone(),
2857        count: Some(LIST_LOOKUP_MAX_RESULTS),
2858        exact: query.exact,
2859        version: None,
2860        channel: None,
2861        locale: None,
2862        installer_type: None,
2863        installer_architecture: None,
2864        platform: None,
2865        os_version: None,
2866        install_scope: query.install_scope.clone(),
2867    }
2868}
2869
2870fn allow_loose_list_correlation(query: &ListQuery) -> bool {
2871    query.query.is_some() || query.id.is_some() || query.name.is_some()
2872}
2873
2874fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
2875    package
2876        .correlated
2877        .as_ref()
2878        .and_then(|candidate| candidate.version.as_deref())
2879        .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
2880}
2881
2882fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
2883    package.installed_version.eq_ignore_ascii_case("Unknown")
2884}
2885
2886fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
2887    // Hide RequireExplicitUpgrade rows from bulk `upgrade` (Edge, Steam,
2888    // Discord). Explicit `--id` filters surface them regardless.
2889    if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) {
2890        return false;
2891    }
2892    // Hide rows whose latest catalog version has no installer the host can
2893    // run — winget refuses to upgrade past an unusable architecture.
2894    if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) {
2895        return false;
2896    }
2897    installed_package_has_upgrade(package)
2898        || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
2899}
2900
2901/// Collapses installed entries that correlate to the same catalog package id
2902/// (case-insensitive within a source) down to a single representative — the
2903/// one with the highest installed version. Uncorrelated entries pass through
2904/// untouched; they can't appear in `upgrade` output anyway (the upgrade
2905/// filter requires a correlation), so they're effectively a no-op here.
2906fn dedupe_correlated_for_upgrade(packages: Vec<InstalledPackage>) -> Vec<InstalledPackage> {
2907    use std::collections::HashMap;
2908    let mut by_id: HashMap<(String, String), InstalledPackage> = HashMap::new();
2909    let mut uncorrelated: Vec<InstalledPackage> = Vec::new();
2910
2911    for package in packages {
2912        match package.correlated.as_ref() {
2913            Some(candidate) => {
2914                let key = (
2915                    candidate.id.to_ascii_lowercase(),
2916                    candidate.source_name.to_ascii_lowercase(),
2917                );
2918                let keep_new = match by_id.get(&key) {
2919                    Some(existing) => {
2920                        // Prefer rows whose `installed_version` was remapped to
2921                        // a catalog Version (via aMiV/aMaV). Comparing a raw
2922                        // ARP DisplayVersion like `40.10.18029` against the
2923                        // catalog's `10.0.300` is meaningless and would also
2924                        // hide the row that legitimately upgrades to it.
2925                        match (
2926                            package.installed_version_canonical,
2927                            existing.installed_version_canonical,
2928                        ) {
2929                            (true, false) => true,
2930                            (false, true) => false,
2931                            _ => {
2932                                compare_version(&package.installed_version, &existing.installed_version)
2933                                    == Ordering::Greater
2934                            }
2935                        }
2936                    }
2937                    None => true,
2938                };
2939                if keep_new {
2940                    by_id.insert(key, package);
2941                }
2942            }
2943            None => uncorrelated.push(package),
2944        }
2945    }
2946
2947    let mut result = uncorrelated;
2948    result.extend(by_id.into_values());
2949    result
2950}
2951
2952fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
2953    let mut source_specific = None;
2954    let mut source_agnostic = None;
2955
2956    for pin in pins {
2957        if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
2958            continue;
2959        }
2960
2961        if pin.source_id.is_empty() {
2962            if source_agnostic.is_none() {
2963                source_agnostic = Some(pin);
2964            }
2965        } else if item
2966            .source_name
2967            .as_deref()
2968            .is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
2969        {
2970            source_specific = Some(pin);
2971            break;
2972        }
2973    }
2974
2975    source_specific.or(source_agnostic)
2976}
2977
2978fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
2979    let Some(available_version) = item.available_version.as_deref() else {
2980        return false;
2981    };
2982    let Some(pin) = find_applicable_pin(item, pins) else {
2983        return false;
2984    };
2985
2986    match pin.pin_type {
2987        PinType::Blocking => true,
2988        PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
2989    }
2990}
2991
2992fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
2993    if pattern.trim().is_empty() || pattern == "*" {
2994        return true;
2995    }
2996
2997    if !pattern.contains('*') {
2998        return version.eq_ignore_ascii_case(pattern);
2999    }
3000
3001    if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
3002        let prefix = &pattern[..pattern.len() - 1];
3003        return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
3004    }
3005
3006    version.eq_ignore_ascii_case(pattern)
3007}
3008
3009fn list_match_from_installed(package: InstalledPackage) -> ListMatch {
3010    let available_version = package.correlated.as_ref().and_then(|candidate| {
3011        candidate.version.as_ref().and_then(|candidate_version| {
3012            if installed_package_has_unknown_version(&package)
3013                || compare_version(candidate_version, &package.installed_version) == Ordering::Greater
3014            {
3015                Some(candidate_version.clone())
3016            } else {
3017                None
3018            }
3019        })
3020    });
3021    let source_name = package
3022        .correlated
3023        .as_ref()
3024        .map(|candidate| candidate.source_name.clone())
3025        .filter(|value| !value.is_empty());
3026    let id = package
3027        .correlated
3028        .as_ref()
3029        .map(|candidate| candidate.id.clone())
3030        .unwrap_or_else(|| package.local_id.clone());
3031
3032    ListMatch {
3033        name: package.name,
3034        id,
3035        local_id: package.local_id,
3036        installed_version: package.installed_version,
3037        available_version,
3038        source_name,
3039        publisher: package.publisher,
3040        scope: package.scope,
3041        installer_category: package.installer_category,
3042        install_location: package.install_location,
3043        package_family_names: package.package_family_names,
3044        product_codes: package.product_codes,
3045        upgrade_codes: package.upgrade_codes,
3046    }
3047}
3048
3049fn list_sort_weight(package: &InstalledPackage) -> usize {
3050    if package.local_id.starts_with("ARP\\") {
3051        0
3052    } else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
3053        1
3054    } else {
3055        2
3056    }
3057}
3058
3059fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
3060    let correlated = package.correlated.as_ref();
3061
3062    if let Some(value) = &query.id {
3063        let local_match = matches_text(&package.local_id, value, query.exact);
3064        let correlated_match = correlated
3065            .map(|candidate| matches_text(&candidate.id, value, query.exact))
3066            .unwrap_or(false);
3067        if !local_match && !correlated_match {
3068            return false;
3069        }
3070    }
3071
3072    if let Some(value) = &query.name
3073        && !matches_text(&package.name, value, query.exact)
3074    {
3075        return false;
3076    }
3077
3078    if let Some(value) = &query.product_code
3079        && !package
3080            .product_codes
3081            .iter()
3082            .any(|code| code.eq_ignore_ascii_case(value))
3083    {
3084        return false;
3085    }
3086
3087    if let Some(value) = &query.version
3088        && !package.installed_version.eq_ignore_ascii_case(value)
3089    {
3090        return false;
3091    }
3092
3093    if let Some(value) = &query.query {
3094        let local_match =
3095            matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
3096        let correlated_match = correlated
3097            .map(|candidate| {
3098                matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
3099            })
3100            .unwrap_or(false);
3101        if !local_match && !correlated_match {
3102            return false;
3103        }
3104    }
3105
3106    if let Some(source) = &query.source
3107        && correlated
3108            .map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
3109            .unwrap_or(true)
3110    {
3111        return false;
3112    }
3113
3114    if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
3115        return false;
3116    }
3117
3118    true
3119}
3120
3121fn correlate_installed_package(
3122    package: &InstalledPackage,
3123    candidates: &[SearchMatch],
3124    allow_loose_name_match: bool,
3125) -> Option<SearchMatch> {
3126    // MSIX entries can only be correlated through their PackageFamilyName.
3127    // That lookup happens earlier in `correlate_installed_via_index`; if it
3128    // didn't find a match, name-based fallback is wrong — the catalog
3129    // doesn't carry this MSIX's identity, so any name collision (e.g. the
3130    // self-updating `Microsoft Edge Stable` MSIX or the Store stub
3131    // `Notepad++` MSIX) would manufacture a phantom correlation that winget
3132    // doesn't make.
3133    if package.local_id.starts_with("MSIX\\") {
3134        return None;
3135    }
3136
3137    let installed_name = normalize_correlation_name(&package.name);
3138    let installed_name_lower = package.name.to_ascii_lowercase();
3139    let candidate_names = correlation_name_candidates(&package.name);
3140
3141    let mut scored: Vec<(i32, SearchMatch)> = candidates
3142        .iter()
3143        .filter_map(|candidate| {
3144            let candidate_name = normalize_correlation_name(&candidate.name);
3145            let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
3146                1000
3147            } else if candidate_names.iter().any(|name| {
3148                let normalized = normalize_correlation_name(name);
3149                normalized == candidate_name
3150            }) {
3151                900
3152            } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
3153                700
3154            } else {
3155                0
3156            };
3157
3158            if base_score == 0 {
3159                return None;
3160            }
3161
3162            // Tiebreaker so we pick the *right* candidate when multiple catalog
3163            // entries collapse to the same normalized name or all loose-match a
3164            // long installed name. Two failure modes this addresses:
3165            //   • `Notepad++` and `Notepad--` both normalize to `notepad` (the
3166            //     alphanumeric filter strips `+`/`-`), so both score 900 and
3167            //     `max_by_key` arbitrarily picked the latter.
3168            //   • Loose substring match picks up `Studio` inside
3169            //     `...from Visual Studio` and lets `ZeroBrane.Studio` outrank
3170            //     `Microsoft.DotNet.SDK.10` when both score 700.
3171            // The bonus rewards catalog names that appear verbatim in the
3172            // installed display name — strongly when they anchor the start,
3173            // weaker when they're embedded.
3174            let candidate_lower = candidate.name.to_ascii_lowercase();
3175            let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
3176                100
3177            } else if installed_name_lower.contains(&candidate_lower) {
3178                50
3179            } else {
3180                0
3181            };
3182
3183            Some((base_score + prefix_bonus, candidate.clone()))
3184        })
3185        .collect();
3186
3187    scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
3188    let mut iter = scored.into_iter();
3189    let (top_score, top_match) = iter.next()?;
3190
3191    // Reject ambiguous wins. When the user has e.g. `Git` (publisher "The Git
3192    // Development Community") installed, both `Git.Git` and `Microsoft.Git`
3193    // normalize their names to `git` and tie at the same score — the only
3194    // signal that disambiguates them, publisher, isn't part of this scoring
3195    // function. winget refuses the correlation in this case (the install
3196    // shows up with an empty Source); we do the same to avoid manufacturing
3197    // a fake upgrade against the wrong package.
3198    if let Some((next_score, _)) = iter.next()
3199        && next_score == top_score
3200    {
3201        return None;
3202    }
3203    Some(top_match)
3204}
3205
3206fn correlation_name_candidates(name: &str) -> Vec<String> {
3207    let mut candidates = Vec::new();
3208    candidates.push(name.trim().to_owned());
3209
3210    let mut trimmed = name.trim().to_owned();
3211    if let Some(index) = trimmed.find(" (") {
3212        trimmed.truncate(index);
3213    }
3214
3215    let mut words = Vec::new();
3216    for token in trimmed.split_whitespace() {
3217        let lower = token
3218            .trim_matches(|ch: char| ch == '(' || ch == ')')
3219            .to_ascii_lowercase();
3220        if !words.is_empty()
3221            && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
3222        {
3223            break;
3224        }
3225        words.push(token);
3226    }
3227
3228    if !words.is_empty() {
3229        candidates.push(words.join(" "));
3230    }
3231
3232    candidates.sort();
3233    candidates.dedup();
3234    candidates
3235}
3236
3237fn normalize_correlation_name(value: &str) -> String {
3238    value
3239        .chars()
3240        .filter(|ch| ch.is_ascii_alphanumeric())
3241        .flat_map(|ch| ch.to_lowercase())
3242        .collect()
3243}
3244
3245struct V2PackageMetadata {
3246    rowid: i64,
3247    id: String,
3248    name: String,
3249    moniker: Option<String>,
3250    latest_version: String,
3251    package_hash: String,
3252}
3253
3254/// Looks up a v2 catalog package whose identity (PackageFamilyName, ProductCode,
3255/// or UpgradeCode) matches the installed package. Returns the `packages.rowid`
3256/// and the field that produced the match. This is winget's primary correlation
3257/// path; falling back to display-name matching is only correct for ARP entries
3258/// that lack all three identifiers.
3259fn lookup_identity_match_v2(
3260    connection: &Connection,
3261    package: &InstalledPackage,
3262) -> Result<Option<(i64, &'static str)>> {
3263    for pfn in &package.package_family_names {
3264        if let Some(rowid) = query_optional_value(
3265            connection,
3266            "SELECT package FROM pfns2 WHERE pfn = ?1 LIMIT 1",
3267            vec![SqlValue::Text(pfn.to_ascii_lowercase())],
3268            |row| row_i64(row, 0),
3269        )? {
3270            return Ok(Some((rowid, "PackageFamilyName")));
3271        }
3272    }
3273    // UpgradeCode wins over ProductCode. The UpgradeCode is the MSI *family*
3274    // identity — it stays stable across versions, while ProductCode changes
3275    // every release. When a single ProductCode happens to also be listed
3276    // under a sibling catalog package (e.g. `OpenJS.NodeJS.LTS` carries
3277    // installer rows from earlier LTS-line ProductCodes, while the v24
3278    // install actually belongs to `OpenJS.NodeJS.22` via its UpgradeCode),
3279    // the upgrade-side match represents the user's "package family" — the
3280    // one winget uses.
3281    for code in &package.upgrade_codes {
3282        if let Some(rowid) = query_optional_value(
3283            connection,
3284            "SELECT package FROM upgradecodes2 WHERE upgradecode = ?1 LIMIT 1",
3285            vec![SqlValue::Text(code.to_ascii_lowercase())],
3286            |row| row_i64(row, 0),
3287        )? {
3288            return Ok(Some((rowid, "UpgradeCode")));
3289        }
3290    }
3291    for code in &package.product_codes {
3292        if let Some(rowid) = query_optional_value(
3293            connection,
3294            "SELECT package FROM productcodes2 WHERE productcode = ?1 LIMIT 1",
3295            vec![SqlValue::Text(code.to_ascii_lowercase())],
3296            |row| row_i64(row, 0),
3297        )? {
3298            return Ok(Some((rowid, "ProductCode")));
3299        }
3300    }
3301    Ok(None)
3302}
3303
3304/// Resolves a v2 package by its catalog id back to its rowid + hash so the
3305/// post-correlation enrichment pass can load versionData for rows that
3306/// correlated through the name-based fallback (which throws the rowid away).
3307fn lookup_v2_metadata_by_id(connection: &Connection, package_id: &str) -> Result<Option<V2PackageMetadata>> {
3308    query_optional_value(
3309        connection,
3310        "SELECT rowid, id, name, moniker, latest_version, hash FROM packages WHERE id = ?1 COLLATE NOCASE LIMIT 1",
3311        vec![SqlValue::Text(package_id.to_owned())],
3312        |row| {
3313            Ok(V2PackageMetadata {
3314                rowid: row_i64(row, 0)?,
3315                id: row_string(row, 1)?,
3316                name: row_string(row, 2)?,
3317                moniker: row_opt_string(row, 3)?,
3318                latest_version: row_string(row, 4)?,
3319                package_hash: row_hex_string(row, 5)?,
3320            })
3321        },
3322    )
3323}
3324
3325/// MSIX entries whose Windows resource-string display name (e.g.
3326/// `ms-resource:appDisplayName`) hasn't been resolved by the AppX runtime
3327/// would otherwise leak that placeholder into pinget's output. winget
3328/// resolves it via `Windows.Management.Deployment` and shows the catalog's
3329/// `PackageName`; we mirror that by substituting the correlated catalog
3330/// name once the package is correlated, which is the parity-faithful
3331/// substitute we can produce without taking a WinRT dependency.
3332fn apply_msix_resource_string_name_fix(package: &mut InstalledPackage) {
3333    if !package.local_id.starts_with("MSIX\\") {
3334        return;
3335    }
3336    if !package.name.starts_with("ms-resource:") {
3337        return;
3338    }
3339    let Some(candidate) = package.correlated.as_ref() else {
3340        return;
3341    };
3342    if !candidate.name.is_empty() {
3343        package.name = candidate.name.clone();
3344    }
3345}
3346
3347fn fetch_v2_package_metadata(connection: &Connection, package_rowid: i64) -> Result<Option<V2PackageMetadata>> {
3348    query_optional_value(
3349        connection,
3350        "SELECT id, name, moniker, latest_version, hash FROM packages WHERE rowid = ?1",
3351        vec![SqlValue::Integer(package_rowid)],
3352        |row| {
3353            Ok(V2PackageMetadata {
3354                rowid: package_rowid,
3355                id: row_string(row, 0)?,
3356                name: row_string(row, 1)?,
3357                moniker: row_opt_string(row, 2)?,
3358                latest_version: row_string(row, 3)?,
3359                package_hash: row_hex_string(row, 4)?,
3360            })
3361        },
3362    )
3363}
3364
3365fn v2_normalized_identity_tables_present(connection: &Connection) -> bool {
3366    query_optional_value(
3367        connection,
3368        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'norm_names2' LIMIT 1",
3369        Vec::new(),
3370        |row| row_i64(row, 0),
3371    )
3372    .ok()
3373    .flatten()
3374    .is_some()
3375}
3376
3377/// Returns the catalog `packages.rowid` whose `(norm_name, norm_publisher)`
3378/// pair matches the installed package — if and only if the match is
3379/// unique. Multiple matches mean the normalization wasn't strong enough to
3380/// disambiguate (e.g. an ARP DisplayName that normalizes the same as two
3381/// distinct catalog packages); winget refuses to correlate in that case
3382/// and we do the same.
3383fn lookup_unique_normalized_identity(
3384    connection: &Connection,
3385    norm_name: &str,
3386    norm_publisher: &str,
3387) -> Result<Option<i64>> {
3388    let rows: Vec<i64> = query_rows(
3389        connection,
3390        "SELECT DISTINCT n.package FROM norm_names2 n \
3391         WHERE n.norm_name = ?1 \
3392         AND EXISTS (SELECT 1 FROM norm_publishers2 p WHERE p.package = n.package AND p.norm_publisher = ?2) \
3393         LIMIT 2",
3394        vec![
3395            SqlValue::Text(norm_name.to_owned()),
3396            SqlValue::Text(norm_publisher.to_owned()),
3397        ],
3398        |row| row_i64(row, 0),
3399    )?;
3400    if rows.len() == 1 { Ok(Some(rows[0])) } else { Ok(None) }
3401}
3402
3403fn v2_identity_tables_present(connection: &Connection) -> bool {
3404    query_optional_value(
3405        connection,
3406        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'productcodes2' LIMIT 1",
3407        Vec::new(),
3408        |row| row_i64(row, 0),
3409    )
3410    .ok()
3411    .flatten()
3412    .is_some()
3413}
3414
3415/// Translates an ARP DisplayVersion to the catalog Version it maps to.
3416/// For packages like the .NET SDK or Python the ARP version is an MSI build
3417/// number (e.g. `10.1.826.23019`) unrelated to the public version
3418/// (`10.0.108`). winget records the ARP range per catalog version in
3419/// versionData.mszyml as `aMiV` / `aMaV`; we look for the entry whose range
3420/// contains the installed version.
3421fn map_arp_version_to_catalog(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
3422    if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
3423        return None;
3424    }
3425    for entry in entries {
3426        let (Some(min), Some(max)) = (entry.arp_min_version.as_deref(), entry.arp_max_version.as_deref()) else {
3427            continue;
3428        };
3429        if compare_version(arp_version, min) != Ordering::Less && compare_version(arp_version, max) != Ordering::Greater
3430        {
3431            return Some(entry.version.clone());
3432        }
3433    }
3434    None
3435}
3436
3437/// Returns the latest catalog Version that carries ARP-range metadata
3438/// (`aMiV` / `aMaV`). Packages like `Microsoft.WindowsAppRuntime.1.8` also
3439/// publish "internal" version rows whose `v` is an MSI build number
3440/// (`8000.836.2153.0`) without ARP bounds. Those rows are not user-facing
3441/// upgrade targets — when the installed side was matched through an ARP
3442/// range, the available side must be a peer that also exposes ARP bounds so
3443/// the two versions compare on the same scale. Returns `None` when no entry
3444/// has bounds; the caller should fall back to `packages.latest_version`.
3445fn latest_arp_anchored_version(entries: &[PackageVersionDataEntry]) -> Option<String> {
3446    entries
3447        .iter()
3448        .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
3449        .max_by(|a, b| compare_version(&a.version, &b.version))
3450        .map(|entry| entry.version.clone())
3451}
3452
3453#[cfg(windows)]
3454fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
3455    let mut packages = Vec::new();
3456    let mut seen = BTreeSet::new();
3457
3458    let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
3459    let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
3460
3461    // MSI registers UpgradeCode → ProductCode mappings under
3462    // `HKLM\SOFTWARE\Classes\Installer\UpgradeCodes` (per-machine) and
3463    // `HKCU\Software\Microsoft\Installer\UpgradeCodes` (per-user). Most ARP
3464    // entries don't expose `UpgradeCode` directly, so winget reads it from
3465    // here. Without it, packages like `OpenJS.NodeJS.22` (correlated only via
3466    // UpgradeCode in the v2 index) fall back to a sibling correlation (e.g.
3467    // `OpenJS.NodeJS.LTS`) that manufactures a spurious upgrade.
3468    let upgrade_codes = collect_msi_upgrade_codes(machine, user);
3469
3470    if machine {
3471        collect_uninstall_view(
3472            &mut packages,
3473            &mut seen,
3474            &upgrade_codes,
3475            RegKey::predef(HKEY_LOCAL_MACHINE),
3476            "Machine",
3477            "X64",
3478            KEY_READ | KEY_WOW64_64KEY,
3479        )?;
3480        collect_uninstall_view(
3481            &mut packages,
3482            &mut seen,
3483            &upgrade_codes,
3484            RegKey::predef(HKEY_LOCAL_MACHINE),
3485            "Machine",
3486            "X86",
3487            KEY_READ | KEY_WOW64_32KEY,
3488        )?;
3489        collect_appmodel_packages(
3490            &mut packages,
3491            &mut seen,
3492            RegKey::predef(HKEY_LOCAL_MACHINE),
3493            "Machine",
3494            KEY_READ | KEY_WOW64_64KEY,
3495        )?;
3496    }
3497
3498    if user {
3499        collect_uninstall_view(
3500            &mut packages,
3501            &mut seen,
3502            &upgrade_codes,
3503            RegKey::predef(HKEY_CURRENT_USER),
3504            "User",
3505            "X64",
3506            KEY_READ,
3507        )?;
3508        collect_appmodel_packages(
3509            &mut packages,
3510            &mut seen,
3511            RegKey::predef(HKEY_CURRENT_USER),
3512            "User",
3513            KEY_READ,
3514        )?;
3515    }
3516
3517    Ok(packages)
3518}
3519
3520/// Builds a `ProductCode → UpgradeCode` map (both in standard
3521/// `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}` lowercase format) by walking the
3522/// MSI Installer registry. Subkeys under `Installer\UpgradeCodes` are named
3523/// by the flipped UpgradeCode GUID, and each subkey's *value names* are the
3524/// flipped ProductCodes registered under that UpgradeCode.
3525#[cfg(windows)]
3526fn collect_msi_upgrade_codes(include_machine: bool, include_user: bool) -> std::collections::HashMap<String, String> {
3527    use std::collections::HashMap;
3528    let mut map = HashMap::new();
3529    if include_machine {
3530        collect_msi_upgrade_codes_from(
3531            RegKey::predef(HKEY_LOCAL_MACHINE),
3532            r"SOFTWARE\Classes\Installer\UpgradeCodes",
3533            KEY_READ | KEY_WOW64_64KEY,
3534            &mut map,
3535        );
3536    }
3537    if include_user {
3538        collect_msi_upgrade_codes_from(
3539            RegKey::predef(HKEY_CURRENT_USER),
3540            r"Software\Microsoft\Installer\UpgradeCodes",
3541            KEY_READ,
3542            &mut map,
3543        );
3544    }
3545    map
3546}
3547
3548#[cfg(windows)]
3549fn collect_msi_upgrade_codes_from(
3550    root: RegKey,
3551    path: &str,
3552    flags: u32,
3553    map: &mut std::collections::HashMap<String, String>,
3554) {
3555    let Ok(upgrade_codes) = root.open_subkey_with_flags(path, flags) else {
3556        return;
3557    };
3558    for key_name in upgrade_codes.enum_keys().flatten() {
3559        let Some(upgrade_code) = unflip_packed_guid(&key_name) else {
3560            continue;
3561        };
3562        let Ok(subkey) = upgrade_codes.open_subkey_with_flags(&key_name, flags) else {
3563            continue;
3564        };
3565        for (value_name, _) in subkey.enum_values().flatten() {
3566            if let Some(product_code) = unflip_packed_guid(&value_name) {
3567                // ARP-side keys are likely to win the latest insert; first one
3568                // is fine — we just need *any* mapping. Use `entry` so the
3569                // first writer per ProductCode wins to stay deterministic.
3570                map.entry(product_code).or_insert_with(|| upgrade_code.clone());
3571            }
3572        }
3573    }
3574}
3575
3576/// Converts the 32-character "packed GUID" used inside the MSI Installer
3577/// registry hive back to the standard `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}`
3578/// lowercase form. The packing reverses each of the 11 chunks (sized 8/4/4
3579/// then eight 2-char byte pairs) of the GUID's hex representation.
3580#[cfg(windows)]
3581fn unflip_packed_guid(packed: &str) -> Option<String> {
3582    if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) {
3583        return None;
3584    }
3585    const CHUNKS: [usize; 11] = [8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2];
3586    let bytes = packed.as_bytes();
3587    let mut reversed = String::with_capacity(32);
3588    let mut offset = 0;
3589    for size in CHUNKS {
3590        let chunk = &bytes[offset..offset + size];
3591        for &c in chunk.iter().rev() {
3592            reversed.push(c as char);
3593        }
3594        offset += size;
3595    }
3596    let r = reversed.to_ascii_lowercase();
3597    Some(format!(
3598        "{{{}-{}-{}-{}-{}}}",
3599        &r[0..8],
3600        &r[8..12],
3601        &r[12..16],
3602        &r[16..20],
3603        &r[20..32]
3604    ))
3605}
3606
3607#[cfg(not(windows))]
3608fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
3609    Ok(Vec::new())
3610}
3611
3612#[cfg(windows)]
3613fn collect_uninstall_view(
3614    packages: &mut Vec<InstalledPackage>,
3615    seen: &mut BTreeSet<String>,
3616    upgrade_code_map: &std::collections::HashMap<String, String>,
3617    root: RegKey,
3618    scope: &str,
3619    arch: &str,
3620    flags: u32,
3621) -> Result<()> {
3622    const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
3623
3624    let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
3625        Ok(key) => key,
3626        Err(_) => return Ok(()),
3627    };
3628
3629    for key_name in uninstall.enum_keys().flatten() {
3630        let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
3631            Ok(key) => key,
3632            Err(_) => continue,
3633        };
3634        if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
3635        {
3636            continue;
3637        }
3638
3639        let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
3640            continue;
3641        };
3642
3643        let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
3644        let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
3645        let publisher = read_reg_string(&subkey, "Publisher");
3646        let install_location = read_reg_string(&subkey, "InstallLocation");
3647        let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
3648            .into_iter()
3649            .collect::<Vec<_>>();
3650        let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
3651        if product_codes.is_empty() && looks_like_product_code(&key_name) {
3652            product_codes.push(key_name.to_ascii_lowercase());
3653        }
3654        let mut upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
3655        if upgrade_codes.is_empty() {
3656            // ARP rarely exposes UpgradeCode directly. Recover it from the MSI
3657            // Installer registry hive so identity correlation can fall back to
3658            // upgradecodes2 when productcodes2 picks the wrong sibling.
3659            for code in &product_codes {
3660                if let Some(uc) = upgrade_code_map.get(&code.to_ascii_lowercase()) {
3661                    upgrade_codes.push(uc.clone());
3662                    break;
3663                }
3664            }
3665        }
3666        let installer_category =
3667            if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
3668                Some("msi".to_owned())
3669            } else if key_name.starts_with("MSIX\\") {
3670                Some("msix".to_owned())
3671            } else {
3672                Some("exe".to_owned())
3673            };
3674
3675        let dedupe_key = format!(
3676            "{}|{}|{}|{}",
3677            local_id,
3678            name.to_ascii_lowercase(),
3679            installed_version.to_ascii_lowercase(),
3680            publisher.clone().unwrap_or_default().to_ascii_lowercase()
3681        );
3682        if !seen.insert(dedupe_key) {
3683            continue;
3684        }
3685
3686        packages.push(InstalledPackage {
3687            name,
3688            local_id,
3689            installed_version,
3690            publisher,
3691            scope: Some(scope.to_owned()),
3692            installer_category,
3693            install_location,
3694            package_family_names,
3695            product_codes,
3696            upgrade_codes,
3697            correlated: None,
3698            installed_version_canonical: false,
3699            correlated_requires_explicit_upgrade: false,
3700            correlated_lacks_compatible_installer: false,
3701        });
3702    }
3703
3704    Ok(())
3705}
3706
3707#[cfg(windows)]
3708fn collect_appmodel_packages(
3709    packages: &mut Vec<InstalledPackage>,
3710    seen: &mut BTreeSet<String>,
3711    root: RegKey,
3712    scope: &str,
3713    flags: u32,
3714) -> Result<()> {
3715    const APPMODEL_PACKAGES_PATH: &str =
3716        r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
3717
3718    let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
3719        Ok(key) => key,
3720        Err(_) => return Ok(()),
3721    };
3722
3723    for key_name in appmodel.enum_keys().flatten() {
3724        let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
3725            Ok(key) => key,
3726            Err(_) => continue,
3727        };
3728
3729        let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
3730            continue;
3731        };
3732        let install_location = read_reg_string(&subkey, "PackageRootFolder");
3733        if install_location.as_deref().is_some_and(is_windows_system_path) {
3734            continue;
3735        }
3736
3737        let Some(metadata) = parse_msix_package_full_name(&key_name) else {
3738            continue;
3739        };
3740
3741        let local_id = format!(r"MSIX\{key_name}");
3742        let dedupe_key = format!(
3743            "{}|{}|{}",
3744            local_id,
3745            name.to_ascii_lowercase(),
3746            metadata.version.to_ascii_lowercase()
3747        );
3748        if !seen.insert(dedupe_key) {
3749            continue;
3750        }
3751
3752        packages.push(InstalledPackage {
3753            name,
3754            local_id,
3755            installed_version: metadata.version,
3756            publisher: None,
3757            scope: Some(scope.to_owned()),
3758            installer_category: Some("msix".to_owned()),
3759            install_location,
3760            package_family_names: vec![metadata.family_name],
3761            product_codes: Vec::new(),
3762            upgrade_codes: Vec::new(),
3763            correlated: None,
3764            installed_version_canonical: false,
3765            correlated_requires_explicit_upgrade: false,
3766            correlated_lacks_compatible_installer: false,
3767        });
3768    }
3769
3770    Ok(())
3771}
3772
3773#[cfg(windows)]
3774struct ParsedMsixPackageFullName {
3775    version: String,
3776    family_name: String,
3777}
3778
3779#[cfg(windows)]
3780fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
3781    let segments = value.split('_').collect::<Vec<_>>();
3782    if segments.len() < 5 {
3783        return None;
3784    }
3785
3786    let name = segments[..segments.len() - 4].join("_");
3787    if name.is_empty() {
3788        return None;
3789    }
3790
3791    let version = segments[segments.len() - 4].trim();
3792    let resource_id = segments[segments.len() - 2].trim();
3793    let publisher_id = segments[segments.len() - 1].trim();
3794    if version.is_empty() || publisher_id.is_empty() {
3795        return None;
3796    }
3797
3798    let family_name = if resource_id.is_empty() {
3799        format!("{name}_{publisher_id}")
3800    } else {
3801        format!("{name}_{resource_id}_{publisher_id}")
3802    };
3803
3804    Some(ParsedMsixPackageFullName {
3805        version: version.to_owned(),
3806        family_name,
3807    })
3808}
3809
3810#[cfg(windows)]
3811fn is_windows_system_path(path: &str) -> bool {
3812    path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
3813}
3814
3815#[cfg(windows)]
3816fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
3817    key.get_value::<String, _>(value_name)
3818        .ok()
3819        .map(|value| value.trim().to_owned())
3820        .filter(|value| !value.is_empty())
3821}
3822
3823#[cfg(windows)]
3824fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
3825    key.get_value::<u32, _>(value_name).ok()
3826}
3827
3828#[cfg(windows)]
3829fn looks_like_product_code(value: &str) -> bool {
3830    value.starts_with('{') && value.ends_with('}')
3831}
3832
3833fn ensure_app_dirs(app_root: &Path) -> Result<()> {
3834    fs::create_dir_all(app_root).context("failed to create app root directory")?;
3835    if uses_packaged_layout(app_root) {
3836        fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
3837        let user_sources_path = packaged_user_sources_path(app_root)?;
3838        if let Some(parent) = user_sources_path.parent() {
3839            fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
3840        }
3841    } else {
3842        fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
3843    }
3844    Ok(())
3845}
3846
3847fn default_app_root() -> Result<PathBuf> {
3848    if let Some(app_root) = std::env::var_os("PINGET_APPROOT") {
3849        return Ok(PathBuf::from(app_root));
3850    }
3851
3852    let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
3853
3854    #[cfg(windows)]
3855    {
3856        if has_current_package_identity() {
3857            return Ok(local_app_data
3858                .join("Packages")
3859                .join(PACKAGED_FAMILY_NAME)
3860                .join("LocalState"));
3861        }
3862        Ok(local_app_data.join("Devolutions").join("Pinget"))
3863    }
3864
3865    #[cfg(not(windows))]
3866    {
3867        Ok(local_app_data.join("pinget"))
3868    }
3869}
3870
3871#[cfg(windows)]
3872fn has_current_package_identity() -> bool {
3873    // APPMODEL_ERROR_NO_PACKAGE; documented Win32 error returned when the calling
3874    // process has no AppX package identity.
3875    const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
3876
3877    let mut length: u32 = 0;
3878    // SAFETY: GetCurrentPackageFullName accepts a null name buffer when length is 0; it returns
3879    // ERROR_INSUFFICIENT_BUFFER for packaged callers and APPMODEL_ERROR_NO_PACKAGE otherwise.
3880    let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
3881    rc != APPMODEL_ERROR_NO_PACKAGE
3882}
3883
3884fn store_path(app_root: &Path) -> PathBuf {
3885    app_root.join("sources.json")
3886}
3887
3888fn user_settings_path(app_root: &Path) -> PathBuf {
3889    if uses_packaged_layout(app_root) {
3890        app_root.join("settings.json")
3891    } else {
3892        app_root.join("user-settings.json")
3893    }
3894}
3895
3896fn admin_settings_path(app_root: &Path) -> PathBuf {
3897    app_root.join("admin-settings.json")
3898}
3899
3900fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
3901    if uses_packaged_layout(app_root) {
3902        return app_root
3903            .join(packaged_source_type(source.kind))
3904            .join(sanitize_path_segment(&source.identifier));
3905    }
3906
3907    app_root.join("sources").join(sanitize_path_segment(&source.name))
3908}
3909
3910fn preindexed_package_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
3911    source_state_dir(app_root, source).join("source.msix")
3912}
3913
3914fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
3915    source_state_dir(app_root, source).join("index.db")
3916}
3917
3918fn rest_information_cache_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
3919    source_state_dir(app_root, source).join("rest-information.json")
3920}
3921
3922fn rest_manifest_cache_path_with_root(
3923    app_root: &Path,
3924    source: &SourceRecord,
3925    package_id: &str,
3926    version: &str,
3927    channel: &str,
3928) -> PathBuf {
3929    let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel);
3930    let digest = sha256_hex(key.as_bytes());
3931    cache_root(app_root)
3932        .join("REST_M")
3933        .join(sanitize_path_segment(&source.identifier))
3934        .join(format!("{digest}.json"))
3935}
3936
3937fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
3938    cache_root(app_root)
3939        .join(bucket)
3940        .join(sanitize_path_segment(identifier))
3941}
3942
3943fn load_store(app_root: &Path) -> Result<SourceStore> {
3944    if uses_packaged_layout(app_root)
3945        && let Some(store) = load_packaged_store(app_root)?
3946    {
3947        return Ok(store);
3948    }
3949
3950    let path = store_path(app_root);
3951    if !path.exists() {
3952        let store = SourceStore::default();
3953        save_store(app_root, &store)?;
3954        return Ok(store);
3955    }
3956
3957    let bytes = fs::read(path).context("failed to read source store")?;
3958    serde_json::from_slice(&bytes).context("failed to parse source store")
3959}
3960
3961fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
3962    if uses_system_winget_source_commands(app_root) {
3963        bail!("System WinGet source settings must be modified through winget.");
3964    }
3965
3966    if uses_packaged_layout(app_root) {
3967        return save_packaged_store(app_root, store);
3968    }
3969
3970    write_json(store_path(app_root), store)
3971}
3972
3973fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
3974    let user_sources_path = packaged_user_sources_path(app_root)?;
3975    let metadata_path = app_root.join("sources_metadata");
3976    let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
3977    let metadata_yaml = fs::read_to_string(&metadata_path).ok();
3978    load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
3979}
3980
3981fn load_packaged_store_from_streams(
3982    user_sources_yaml: Option<&str>,
3983    metadata_yaml: Option<&str>,
3984) -> Result<Option<SourceStore>> {
3985    if is_secure_settings_stub(user_sources_yaml) {
3986        return load_system_winget_source_store().map(Some);
3987    }
3988
3989    Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
3990}
3991
3992fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
3993    let user_sources_path = packaged_user_sources_path(app_root)?;
3994    if let Some(parent) = user_sources_path.parent() {
3995        fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
3996    }
3997
3998    fs::write(&user_sources_path, render_packaged_sources_yaml(store))
3999        .context("failed to write packaged user_sources stream")?;
4000    fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
4001        .context("failed to write packaged source metadata")?;
4002    Ok(())
4003}
4004
4005fn uses_system_winget_source_commands(app_root: &Path) -> bool {
4006    if !uses_packaged_layout(app_root) {
4007        return false;
4008    }
4009
4010    #[cfg(windows)]
4011    {
4012        let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
4013            return false;
4014        };
4015
4016        let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
4017        is_secure_settings_stub(user_sources_yaml.as_deref())
4018    }
4019
4020    #[cfg(not(windows))]
4021    {
4022        false
4023    }
4024}
4025
4026fn is_secure_settings_stub(value: Option<&str>) -> bool {
4027    value
4028        .map(str::trim_start)
4029        .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
4030}
4031
4032fn load_system_winget_source_store() -> Result<SourceStore> {
4033    let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
4034    Ok(SourceStore {
4035        sources: parse_system_winget_source_export(&result.stdout)?,
4036    })
4037}
4038
4039fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
4040    run_checked_winget_source_command(
4041        build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
4042        &format!("add WinGet source '{name}'"),
4043    )?;
4044    Ok(())
4045}
4046
4047fn system_winget_remove_source(name: &str) -> Result<()> {
4048    run_checked_winget_source_command(
4049        vec![
4050            "source".to_owned(),
4051            "remove".to_owned(),
4052            "--name".to_owned(),
4053            name.to_owned(),
4054            "--disable-interactivity".to_owned(),
4055        ],
4056        &format!("remove WinGet source '{name}'"),
4057    )?;
4058    Ok(())
4059}
4060
4061fn system_winget_reset_source(name: &str) -> Result<()> {
4062    run_checked_winget_source_command(
4063        vec![
4064            "source".to_owned(),
4065            "reset".to_owned(),
4066            "--name".to_owned(),
4067            name.to_owned(),
4068            "--force".to_owned(),
4069            "--disable-interactivity".to_owned(),
4070        ],
4071        &format!("reset WinGet source '{name}'"),
4072    )?;
4073    Ok(())
4074}
4075
4076fn system_winget_reset_sources() -> Result<()> {
4077    run_checked_winget_source_command(
4078        vec![
4079            "source".to_owned(),
4080            "reset".to_owned(),
4081            "--force".to_owned(),
4082            "--disable-interactivity".to_owned(),
4083        ],
4084        "reset WinGet sources",
4085    )?;
4086    Ok(())
4087}
4088
4089fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
4090    let mut args = vec!["source".to_owned(), "update".to_owned()];
4091    if let Some(name) = name {
4092        args.push("--name".to_owned());
4093        args.push(name.to_owned());
4094    }
4095
4096    args.push("--disable-interactivity".to_owned());
4097    let result = run_checked_winget_source_command(
4098        args,
4099        if name.is_some() {
4100            "update WinGet source"
4101        } else {
4102            "update WinGet sources"
4103        },
4104    )?;
4105    let detail = result.stdout.trim();
4106    Ok(if detail.is_empty() {
4107        "Done".to_owned()
4108    } else {
4109        detail.to_owned()
4110    })
4111}
4112
4113fn build_winget_source_export_arguments() -> Vec<String> {
4114    vec![
4115        "source".to_owned(),
4116        "export".to_owned(),
4117        "--disable-interactivity".to_owned(),
4118    ]
4119}
4120
4121fn build_winget_source_add_arguments(
4122    name: &str,
4123    arg: &str,
4124    kind: SourceKind,
4125    trust_level: &str,
4126    explicit: bool,
4127) -> Vec<String> {
4128    let mut args = vec![
4129        "source".to_owned(),
4130        "add".to_owned(),
4131        "--name".to_owned(),
4132        name.to_owned(),
4133        "--arg".to_owned(),
4134        arg.to_owned(),
4135        "--type".to_owned(),
4136        packaged_source_type(kind).to_owned(),
4137        "--disable-interactivity".to_owned(),
4138    ];
4139
4140    if !trust_level.trim().is_empty() {
4141        args.push("--trust-level".to_owned());
4142        args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
4143            "trusted".to_owned()
4144        } else {
4145            "none".to_owned()
4146        });
4147    }
4148
4149    if explicit {
4150        args.push("--explicit".to_owned());
4151    }
4152
4153    args
4154}
4155
4156#[derive(Debug)]
4157struct WingetSourceCommandResult {
4158    exit_code: i32,
4159    stdout: String,
4160    stderr: String,
4161}
4162
4163type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
4164
4165static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
4166    RwLock::new(run_winget_source_command_process);
4167
4168fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
4169    let result = run_winget_source_command(&args)?;
4170    if result.exit_code == 0 {
4171        return Ok(result);
4172    }
4173
4174    let detail = [result.stderr.trim(), result.stdout.trim()]
4175        .into_iter()
4176        .filter(|value| !value.is_empty())
4177        .collect::<Vec<_>>()
4178        .join("\n");
4179    if detail.is_empty() {
4180        bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
4181    }
4182
4183    bail!(
4184        "Failed to {action}; winget exited with code {}:\n{}",
4185        result.exit_code,
4186        detail
4187    );
4188}
4189
4190fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
4191    let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
4192        .read()
4193        .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
4194    runner(args)
4195}
4196
4197fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
4198    use std::process::Command;
4199
4200    let output = Command::new("winget")
4201        .args(args)
4202        .output()
4203        .context("failed to run winget source command")?;
4204
4205    Ok(WingetSourceCommandResult {
4206        exit_code: output.status.code().unwrap_or(-1),
4207        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
4208        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
4209    })
4210}
4211
4212fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
4213    let trimmed = output.trim();
4214    if trimmed.is_empty() {
4215        return Ok(Vec::new());
4216    }
4217
4218    if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
4219        && let Some(sources) = parse_system_winget_source_export_value(&value)?
4220    {
4221        return Ok(order_sources(sources));
4222    }
4223
4224    let mut sources = Vec::new();
4225    for line in output.replace("\r\n", "\n").lines().map(str::trim) {
4226        if line.is_empty() {
4227            continue;
4228        }
4229
4230        let value: JsonValue = serde_json::from_str(line)
4231            .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
4232        let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
4233            bail!("WinGet source export returned a non-source line: {line}");
4234        };
4235        sources.append(&mut parsed);
4236    }
4237
4238    Ok(order_sources(sources))
4239}
4240
4241fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
4242    match value {
4243        JsonValue::Array(items) => items
4244            .iter()
4245            .map(parse_system_winget_source_record)
4246            .collect::<Result<Vec<_>>>()
4247            .map(Some),
4248        JsonValue::Object(object) => {
4249            if let Some(JsonValue::Array(items)) = object.get("Sources") {
4250                items
4251                    .iter()
4252                    .map(parse_system_winget_source_record)
4253                    .collect::<Result<Vec<_>>>()
4254                    .map(Some)
4255            } else if object.contains_key("Name") {
4256                Ok(Some(vec![parse_system_winget_source_record(value)?]))
4257            } else {
4258                Ok(None)
4259            }
4260        }
4261        _ => Ok(None),
4262    }
4263}
4264
4265fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
4266    let object = value
4267        .as_object()
4268        .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
4269    let name = required_json_string(object, "Name")?;
4270    let source_type = required_json_string(object, "Type")?;
4271    let arg = required_json_string(object, "Arg")?;
4272    let kind = parse_system_winget_source_kind(&source_type)?;
4273    let identifier = optional_json_string(object, "Identifier")
4274        .or_else(|| optional_json_string(object, "Data"))
4275        .unwrap_or_else(|| name.clone());
4276
4277    Ok(SourceRecord {
4278        name,
4279        kind,
4280        arg,
4281        identifier,
4282        trust_level: parse_system_winget_trust_level(object),
4283        explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
4284        priority: optional_json_i32(object, "Priority").unwrap_or_default(),
4285        last_update: None,
4286        source_version: None,
4287    })
4288}
4289
4290fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
4291    optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
4292}
4293
4294fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
4295    match object.get(key)? {
4296        JsonValue::String(value) => Some(value.clone()),
4297        JsonValue::Number(value) => Some(value.to_string()),
4298        _ => None,
4299    }
4300}
4301
4302fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
4303    match object.get(key)? {
4304        JsonValue::Bool(value) => Some(*value),
4305        JsonValue::String(value) => value.parse().ok(),
4306        _ => None,
4307    }
4308}
4309
4310fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
4311    match object.get(key)? {
4312        JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
4313        JsonValue::String(value) => value.parse().ok(),
4314        _ => None,
4315    }
4316}
4317
4318fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
4319    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
4320        Ok(SourceKind::PreIndexed)
4321    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
4322        Ok(SourceKind::Rest)
4323    } else {
4324        bail!("Unsupported WinGet source type '{value}'.")
4325    }
4326}
4327
4328fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
4329    let Some(value) = object.get("TrustLevel") else {
4330        return "None".to_owned();
4331    };
4332
4333    match value {
4334        JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
4335        JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
4336        JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
4337        _ => "None".to_owned(),
4338    }
4339}
4340
4341fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
4342    value
4343        .as_str()
4344        .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
4345}
4346
4347fn order_sources(mut sources: Vec<SourceRecord>) -> Vec<SourceRecord> {
4348    sources.sort_by(|left, right| {
4349        left.explicit
4350            .cmp(&right.explicit)
4351            .then_with(|| right.priority.cmp(&left.priority))
4352            .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
4353    });
4354    sources
4355}
4356
4357fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
4358    if user_sources_yaml.is_none() && metadata_yaml.is_none() {
4359        return None;
4360    }
4361
4362    let mut sources = SourceStore::default().sources;
4363    for entry in parse_packaged_yaml_entries(user_sources_yaml) {
4364        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
4365            continue;
4366        };
4367
4368        if yaml_bool(entry.get("IsTombstone")) {
4369            sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
4370            continue;
4371        }
4372
4373        let Some(mapped) = map_packaged_source_entry(&entry) else {
4374            continue;
4375        };
4376
4377        if let Some(existing) = sources
4378            .iter_mut()
4379            .find(|source| source.name.eq_ignore_ascii_case(&name))
4380        {
4381            *existing = mapped;
4382        } else {
4383            sources.push(mapped);
4384        }
4385    }
4386
4387    for entry in parse_packaged_yaml_entries(metadata_yaml) {
4388        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
4389            continue;
4390        };
4391
4392        let Some(source) = sources
4393            .iter_mut()
4394            .find(|source| source.name.eq_ignore_ascii_case(&name))
4395        else {
4396            continue;
4397        };
4398
4399        if let Some(last_update) = entry
4400            .get("LastUpdate")
4401            .and_then(|value| value.as_deref())
4402            .and_then(parse_packaged_datetime)
4403        {
4404            source.last_update = Some(last_update);
4405        }
4406
4407        if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
4408            && !source_version.is_empty()
4409        {
4410            source.source_version = Some(source_version);
4411        }
4412    }
4413
4414    Some(SourceStore { sources })
4415}
4416
4417fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
4418    let Some(yaml) = yaml else {
4419        return Vec::new();
4420    };
4421
4422    let mut entries = Vec::new();
4423    let mut current: Option<BTreeMap<String, Option<String>>> = None;
4424    for raw_line in yaml.replace("\r\n", "\n").split('\n') {
4425        let line = raw_line.trim_end();
4426        let trimmed = line.trim();
4427        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
4428            continue;
4429        }
4430
4431        if let Some(remainder) = trimmed.strip_prefix("- ") {
4432            if let Some(entry) = current.take() {
4433                entries.push(entry);
4434            }
4435
4436            let mut entry = BTreeMap::new();
4437            add_packaged_yaml_key_value(&mut entry, remainder);
4438            current = Some(entry);
4439            continue;
4440        }
4441
4442        if let Some(entry) = current.as_mut() {
4443            add_packaged_yaml_key_value(entry, trimmed);
4444        }
4445    }
4446
4447    if let Some(entry) = current {
4448        entries.push(entry);
4449    }
4450
4451    entries
4452}
4453
4454fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
4455    let Some((key, value)) = line.split_once(':') else {
4456        return;
4457    };
4458
4459    entry.insert(
4460        key.trim().to_owned(),
4461        Some(unquote_yaml_scalar(value.trim()).to_owned()),
4462    );
4463}
4464
4465fn unquote_yaml_scalar(value: &str) -> &str {
4466    if value.len() >= 2 {
4467        let bytes = value.as_bytes();
4468        let first = bytes[0] as char;
4469        let last = bytes[value.len() - 1] as char;
4470        if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
4471            return &value[1..value.len() - 1];
4472        }
4473    }
4474
4475    value
4476}
4477
4478fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
4479    let name = entry.get("Name").and_then(|value| value.clone())?;
4480    let source_type = entry.get("Type").and_then(|value| value.clone())?;
4481    let arg = entry.get("Arg").and_then(|value| value.clone())?;
4482    let kind = parse_packaged_source_kind(&source_type)?;
4483
4484    Some(SourceRecord {
4485        name: name.clone(),
4486        kind,
4487        arg,
4488        identifier: entry
4489            .get("Identifier")
4490            .and_then(|value| value.clone())
4491            .or_else(|| entry.get("Data").and_then(|value| value.clone()))
4492            .unwrap_or(name),
4493        trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
4494        explicit: yaml_bool(entry.get("Explicit")),
4495        priority: entry
4496            .get("Priority")
4497            .and_then(|value| value.as_deref())
4498            .and_then(|value| value.parse::<i32>().ok())
4499            .unwrap_or_default(),
4500        last_update: None,
4501        source_version: None,
4502    })
4503}
4504
4505fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
4506    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
4507        Some(SourceKind::PreIndexed)
4508    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
4509        Some(SourceKind::Rest)
4510    } else {
4511        None
4512    }
4513}
4514
4515fn parse_packaged_trust_level(value: Option<&str>) -> String {
4516    match value {
4517        Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
4518        Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
4519        _ => "None".to_owned(),
4520    }
4521}
4522
4523fn yaml_bool(value: Option<&Option<String>>) -> bool {
4524    value
4525        .and_then(|raw| raw.as_deref())
4526        .and_then(|raw| raw.parse::<bool>().ok())
4527        .unwrap_or(false)
4528}
4529
4530fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
4531    if let Ok(unix_seconds) = value.parse::<i64>() {
4532        return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
4533    }
4534
4535    DateTime::parse_from_rfc3339(value)
4536        .ok()
4537        .map(|value| value.with_timezone(&Utc))
4538}
4539
4540fn render_packaged_sources_yaml(store: &SourceStore) -> String {
4541    let mut lines = vec!["Sources:".to_owned()];
4542    for source in &store.sources {
4543        lines.push(format!("  - Name: {}", source.name));
4544        lines.push(format!("    Type: {}", packaged_source_type(source.kind)));
4545        lines.push(format!("    Arg: {}", yaml_scalar(&source.arg)));
4546        lines.push(format!("    Data: {}", yaml_scalar(&source.identifier)));
4547        lines.push(format!(
4548            "    TrustLevel: {}",
4549            if source.trust_level.eq_ignore_ascii_case("Trusted") {
4550                1
4551            } else {
4552                0
4553            }
4554        ));
4555        lines.push(format!("    Explicit: {}", source.explicit));
4556        lines.push(format!("    Priority: {}", source.priority));
4557        lines.push("    IsTombstone: false".to_owned());
4558    }
4559    lines.push(String::new());
4560    lines.join("\n")
4561}
4562
4563fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
4564    let mut lines = vec!["Sources:".to_owned()];
4565    for source in &store.sources {
4566        if source.last_update.is_none() && source.source_version.is_none() {
4567            continue;
4568        }
4569
4570        lines.push(format!("  - Name: {}", source.name));
4571        if let Some(last_update) = source.last_update {
4572            lines.push(format!("    LastUpdate: {}", last_update.timestamp()));
4573        }
4574        if let Some(source_version) = &source.source_version {
4575            lines.push(format!("    SourceVersion: {}", yaml_scalar(source_version)));
4576        }
4577    }
4578    lines.push(String::new());
4579    lines.join("\n")
4580}
4581
4582fn yaml_scalar(value: &str) -> String {
4583    if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
4584        format!("\"{}\"", value.replace('"', "\\\""))
4585    } else {
4586        value.to_owned()
4587    }
4588}
4589
4590fn uses_packaged_layout(app_root: &Path) -> bool {
4591    #[cfg(windows)]
4592    {
4593        try_get_packaged_local_state_root(app_root).is_some()
4594    }
4595
4596    #[cfg(not(windows))]
4597    {
4598        let _ = app_root;
4599        false
4600    }
4601}
4602
4603#[cfg(windows)]
4604fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
4605    let local_state = app_root.file_name()?.to_string_lossy();
4606    if !local_state.eq_ignore_ascii_case("LocalState") {
4607        return None;
4608    }
4609
4610    let family = app_root.parent()?.file_name()?.to_string_lossy();
4611    if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
4612        return None;
4613    }
4614
4615    let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
4616    if !packages.eq_ignore_ascii_case("Packages") {
4617        return None;
4618    }
4619
4620    Some(app_root.to_path_buf())
4621}
4622
4623#[cfg(windows)]
4624fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
4625    let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
4626    Ok(packaged_secure_settings_root()?.join("user_sources"))
4627}
4628
4629#[cfg(not(windows))]
4630fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
4631    bail!("packaged WinGet source streams are only available on Windows")
4632}
4633
4634#[cfg(windows)]
4635fn packaged_secure_settings_root() -> Result<PathBuf> {
4636    let program_data = std::env::var_os("PROGRAMDATA")
4637        .map(PathBuf::from)
4638        .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
4639    Ok(program_data
4640        .join("Microsoft")
4641        .join("WinGet")
4642        .join(current_user_sid()?)
4643        .join("settings")
4644        .join("pkg")
4645        .join(PACKAGED_NAME))
4646}
4647
4648#[cfg(windows)]
4649#[allow(clippy::multiple_unsafe_ops_per_block)]
4650fn current_user_sid() -> Result<String> {
4651    fn close_token(token: HANDLE) {
4652        if !token.is_null() {
4653            // SAFETY: token is a HANDLE returned by OpenProcessToken and may be closed once here.
4654            unsafe {
4655                CloseHandle(token);
4656            }
4657        }
4658    }
4659
4660    let mut token: HANDLE = std::ptr::null_mut();
4661    // SAFETY: GetCurrentProcess returns a pseudo-handle valid for OpenProcessToken in this process.
4662    let current_process = unsafe { GetCurrentProcess() };
4663    // SAFETY: token points to valid storage for the process token handle on success.
4664    if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
4665        bail!("failed to open current process token")
4666    }
4667
4668    let mut length = 0;
4669    // SAFETY: The null buffer call asks Windows for the required buffer length.
4670    unsafe {
4671        GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
4672    }
4673
4674    let element_size = size_of::<TOKEN_USER>();
4675    let element_count = (length as usize).div_ceil(element_size);
4676    let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
4677    // SAFETY: buffer is aligned for TOKEN_USER and has at least length bytes of storage.
4678    if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
4679        close_token(token);
4680        bail!("failed to get current user token information")
4681    }
4682
4683    // SAFETY: GetTokenInformation initialized the buffer as a TOKEN_USER structure.
4684    let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
4685    let mut sid_ptr: *mut u16 = std::ptr::null_mut();
4686    // SAFETY: token_user.User.Sid is supplied by Windows and sid_ptr receives a LocalAlloc buffer on success.
4687    if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
4688        close_token(token);
4689        bail!("failed to convert current user SID")
4690    }
4691
4692    let mut len = 0usize;
4693    // SAFETY: sid_ptr is a null-terminated UTF-16 string returned by ConvertSidToStringSidW.
4694    while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
4695        len += 1;
4696    }
4697
4698    // SAFETY: sid_ptr points to len initialized UTF-16 code units.
4699    let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
4700    let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned();
4701    // SAFETY: sid_ptr was allocated by ConvertSidToStringSidW and must be released with LocalFree.
4702    unsafe {
4703        LocalFree(sid_ptr.cast());
4704    }
4705    close_token(token);
4706    Ok(sid)
4707}
4708
4709fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
4710    app_root.join("Microsoft").join("Windows Package Manager")
4711}
4712
4713fn cache_root(app_root: &Path) -> PathBuf {
4714    if uses_packaged_layout(app_root) {
4715        packaged_file_cache_root(app_root)
4716    } else {
4717        default_cache_root_fallback()
4718    }
4719}
4720
4721fn default_cache_root_fallback() -> PathBuf {
4722    std::env::temp_dir().join("cache")
4723}
4724
4725fn packaged_source_type(kind: SourceKind) -> &'static str {
4726    match kind {
4727        SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
4728        SourceKind::Rest => "Microsoft.Rest",
4729    }
4730}
4731
4732fn sanitize_path_segment(value: &str) -> String {
4733    value
4734        .chars()
4735        .map(|ch| match ch {
4736            '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
4737            _ => ch,
4738        })
4739        .collect()
4740}
4741
4742fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
4743    if !path.exists() {
4744        return Ok(JsonMap::new());
4745    }
4746
4747    let bytes = fs::read(path).context("failed to read JSON file")?;
4748    let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
4749    Ok(match value {
4750        JsonValue::Object(object) => object,
4751        _ => JsonMap::new(),
4752    })
4753}
4754
4755fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
4756    write_json(path, value)
4757}
4758
4759fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
4760    if let Some(parent) = path.parent() {
4761        fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
4762    }
4763
4764    let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
4765    fs::write(path, bytes).context("failed to write JSON file")
4766}
4767
4768fn merge_json_objects(
4769    current: &JsonMap<String, JsonValue>,
4770    update: &JsonMap<String, JsonValue>,
4771) -> JsonMap<String, JsonValue> {
4772    let mut merged = current.clone();
4773    for (key, value) in update {
4774        match (merged.get(key), value) {
4775            (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
4776                merged.insert(
4777                    key.clone(),
4778                    JsonValue::Object(merge_json_objects(current_object, update_object)),
4779                );
4780            }
4781            _ => {
4782                merged.insert(key.clone(), value.clone());
4783            }
4784        }
4785    }
4786
4787    merged
4788}
4789
4790fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
4791    match expected {
4792        JsonValue::Object(expected_object) => match current {
4793            JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
4794                current_object
4795                    .get(key)
4796                    .is_some_and(|current_child| json_contains(current_child, expected_child))
4797            }),
4798            _ => false,
4799        },
4800        JsonValue::Array(expected_array) => match current {
4801            JsonValue::Array(current_array) => {
4802                current_array.len() == expected_array.len()
4803                    && current_array
4804                        .iter()
4805                        .zip(expected_array.iter())
4806                        .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
4807            }
4808            _ => false,
4809        },
4810        _ => current == expected,
4811    }
4812}
4813
4814fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
4815    match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
4816        "none" | "default" => Ok("None".to_owned()),
4817        "trusted" => Ok("Trusted".to_owned()),
4818        other => bail!("unsupported source trust level: {other}"),
4819    }
4820}
4821
4822fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
4823    SUPPORTED_ADMIN_SETTINGS
4824        .iter()
4825        .copied()
4826        .find(|setting| setting.eq_ignore_ascii_case(name))
4827        .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
4828}
4829
4830fn default_source_trust_level() -> String {
4831    "None".to_owned()
4832}
4833
4834fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
4835where
4836    F: FnMut(&SqlRow<'_>) -> Result<T>,
4837{
4838    let mut statement = connection
4839        .prepare(sql)
4840        .with_context(|| format!("failed to prepare SQL query: {sql}"))?;
4841    let mut rows = statement
4842        .query(params_from_iter(params))
4843        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
4844    let mut result = Vec::new();
4845
4846    while let Some(row) = rows.next().context("failed to read SQL row")? {
4847        result.push(map(row)?);
4848    }
4849
4850    Ok(result)
4851}
4852
4853fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
4854where
4855    F: FnOnce(&SqlRow<'_>) -> Result<T>,
4856{
4857    let mut statement = connection
4858        .prepare(sql)
4859        .with_context(|| format!("failed to prepare SQL query: {sql}"))?;
4860    let mut rows = statement
4861        .query(params_from_iter(params))
4862        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
4863
4864    match rows.next().context("failed to read SQL row")? {
4865        Some(row) => Ok(Some(map(row)?)),
4866        None => Ok(None),
4867    }
4868}
4869
4870fn row_ref<'a>(row: &'a SqlRow<'_>, index: usize) -> Result<ValueRef<'a>> {
4871    row.get_ref(index)
4872        .with_context(|| format!("failed to read SQL column {index}"))
4873}
4874
4875fn row_string(row: &SqlRow<'_>, index: usize) -> Result<String> {
4876    match row_ref(row, index)? {
4877        ValueRef::Text(value) => std::str::from_utf8(value)
4878            .context("SQL text column was not valid UTF-8")
4879            .map(str::to_owned),
4880        ValueRef::Integer(value) => Ok(value.to_string()),
4881        ValueRef::Real(value) => Ok(value.to_string()),
4882        ValueRef::Blob(value) => String::from_utf8(value.to_vec()).context("SQL blob column was not valid UTF-8"),
4883        ValueRef::Null => bail!("SQL column {index} was unexpectedly NULL"),
4884    }
4885}
4886
4887fn row_opt_string(row: &SqlRow<'_>, index: usize) -> Result<Option<String>> {
4888    match row_ref(row, index)? {
4889        ValueRef::Null => Ok(None),
4890        ValueRef::Text(value) => Ok(Some(
4891            std::str::from_utf8(value)
4892                .context("SQL text column was not valid UTF-8")?
4893                .to_owned(),
4894        )),
4895        ValueRef::Integer(value) => Ok(Some(value.to_string())),
4896        ValueRef::Real(value) => Ok(Some(value.to_string())),
4897        ValueRef::Blob(value) => Ok(Some(
4898            String::from_utf8(value.to_vec()).context("SQL blob column was not valid UTF-8")?,
4899        )),
4900    }
4901}
4902
4903fn row_i64(row: &SqlRow<'_>, index: usize) -> Result<i64> {
4904    match row_ref(row, index)? {
4905        ValueRef::Integer(value) => Ok(value),
4906        ValueRef::Text(value) => std::str::from_utf8(value)
4907            .context("SQL text column was not valid UTF-8")?
4908            .parse::<i64>()
4909            .with_context(|| format!("failed to parse integer from SQL text column {index}")),
4910        ValueRef::Real(value) => f64_to_i64_checked(value, index),
4911        ValueRef::Null => bail!("SQL column {index} was unexpectedly NULL"),
4912        ValueRef::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
4913    }
4914}
4915
4916fn row_hex_string(row: &SqlRow<'_>, index: usize) -> Result<String> {
4917    match row_ref(row, index)? {
4918        ValueRef::Blob(value) => Ok(bytes_to_hex(value)),
4919        ValueRef::Text(value) => Ok(std::str::from_utf8(value)
4920            .context("SQL text column was not valid UTF-8")?
4921            .to_ascii_lowercase()),
4922        ValueRef::Null => bail!("SQL column {index} was unexpectedly NULL"),
4923        ValueRef::Integer(value) => Ok(format!("{value:x}")),
4924        ValueRef::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
4925    }
4926}
4927
4928fn row_opt_hex_string(row: &SqlRow<'_>, index: usize) -> Result<Option<String>> {
4929    match row_ref(row, index)? {
4930        ValueRef::Null => Ok(None),
4931        ValueRef::Blob(value) => Ok(Some(bytes_to_hex(value))),
4932        ValueRef::Text(value) => Ok(Some(
4933            std::str::from_utf8(value)
4934                .context("SQL text column was not valid UTF-8")?
4935                .to_ascii_lowercase(),
4936        )),
4937        ValueRef::Integer(value) => Ok(Some(format!("{value:x}"))),
4938        ValueRef::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
4939    }
4940}
4941
4942fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
4943    if !value.is_finite() {
4944        bail!("SQL column {index} contained a non-finite floating-point value");
4945    }
4946
4947    if value.fract() != 0.0 {
4948        bail!("SQL column {index} contained a non-integer floating-point value");
4949    }
4950
4951    if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
4952        bail!("SQL column {index} contained a floating-point value outside the i64 range");
4953    }
4954
4955    format!("{value:.0}")
4956        .parse::<i64>()
4957        .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
4958}
4959
4960fn bytes_to_hex(bytes: &[u8]) -> String {
4961    let mut result = String::with_capacity(bytes.len() * 2);
4962    for byte in bytes {
4963        result.push_str(&format!("{byte:02x}"));
4964    }
4965    result
4966}
4967
4968fn query_v2_matches(
4969    connection: &Connection,
4970    query: &PackageQuery,
4971    semantics: SearchSemantics,
4972) -> Result<(Vec<V2SearchRow>, bool)> {
4973    let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
4974    let limit = source_fetch_results(query, semantics);
4975    let sql = format!(
4976        "SELECT rowid, id, name, moniker, latest_version, hash \
4977         FROM packages WHERE {where_clause} LIMIT {}",
4978        limit + 1
4979    );
4980    let mut rows = query_rows(
4981        connection,
4982        &sql,
4983        params.into_iter().map(SqlValue::Text).collect(),
4984        |row| {
4985            Ok(V2SearchRow {
4986                package_rowid: row_i64(row, 0)?,
4987                id: row_string(row, 1)?,
4988                name: row_string(row, 2)?,
4989                moniker: row_opt_string(row, 3)?,
4990                version: row_string(row, 4)?,
4991                package_hash: row_hex_string(row, 5)?,
4992                match_criteria: None,
4993            })
4994        },
4995    )?;
4996    for row in &mut rows {
4997        row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
4998    }
4999    let truncated = rows.len() > limit;
5000    rows.truncate(limit);
5001    Ok((rows, truncated))
5002}
5003
5004fn query_v1_matches(
5005    connection: &Connection,
5006    query: &PackageQuery,
5007    semantics: SearchSemantics,
5008) -> Result<(Vec<V1SearchRow>, bool)> {
5009    let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
5010    let limit = source_fetch_results(query, semantics);
5011    let sql = format!(
5012        "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
5013         FROM manifest \
5014         JOIN ids ON manifest.id = ids.rowid \
5015         JOIN names ON manifest.name = names.rowid \
5016         LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
5017         JOIN versions ON manifest.version = versions.rowid \
5018         JOIN channels ON manifest.channel = channels.rowid \
5019         WHERE {where_clause} LIMIT {}",
5020        limit + 1
5021    );
5022    let mut rows = query_rows(
5023        connection,
5024        &sql,
5025        params.into_iter().map(SqlValue::Text).collect(),
5026        |row| {
5027            Ok(V1SearchRow {
5028                manifest_rowid: row_i64(row, 0)?,
5029                package_rowid: row_i64(row, 1)?,
5030                version: row_string(row, 2)?,
5031                channel: row_string(row, 3)?,
5032                id: row_string(row, 4)?,
5033                name: row_string(row, 5)?,
5034                moniker: row_opt_string(row, 6)?,
5035                match_criteria: None,
5036            })
5037        },
5038    )?;
5039    for row in &mut rows {
5040        row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
5041    }
5042    let truncated = rows.len() > limit;
5043    rows.truncate(limit);
5044    Ok((rows, truncated))
5045}
5046
5047fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
5048    let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
5049
5050    for row in rows {
5051        match grouped.get(&row.package_rowid) {
5052            Some(existing)
5053                if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
5054                    != Ordering::Less => {}
5055            _ => {
5056                grouped.insert(row.package_rowid, row);
5057            }
5058        }
5059    }
5060
5061    grouped.into_values().collect()
5062}
5063
5064fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
5065    let has_hash = table_has_column(connection, "manifest", "hash")?;
5066    let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
5067    let sql = format!(
5068        "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
5069         FROM manifest \
5070         JOIN versions ON manifest.version = versions.rowid \
5071         JOIN channels ON manifest.channel = channels.rowid \
5072         WHERE manifest.id = ?1"
5073    );
5074    query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
5075        Ok(V1VersionRow {
5076            version: row_string(row, 0)?,
5077            channel: row_string(row, 1)?,
5078            pathpart_id: row_i64(row, 2)?,
5079            manifest_hash: row_opt_hex_string(row, 3)?,
5080        })
5081    })
5082}
5083
5084fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
5085    let mut parts = Vec::new();
5086    let mut current = Some(pathpart_id);
5087
5088    while let Some(id) = current {
5089        let (parent, pathpart) = query_optional_value(
5090            connection,
5091            "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
5092            vec![SqlValue::Integer(id)],
5093            |row| {
5094                Ok((
5095                    row_opt_string(row, 0)?
5096                        .and_then(|value| value.parse::<i64>().ok())
5097                        .filter(|value| *value != 0),
5098                    row_string(row, 1)?,
5099                ))
5100            },
5101        )?
5102        .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
5103        parts.push(pathpart);
5104        current = parent;
5105    }
5106
5107    parts.reverse();
5108    Ok(parts.join("/"))
5109}
5110
5111fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
5112    format!(
5113        "packages/{}/{}/versionData.mszyml",
5114        package_id,
5115        package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
5116    )
5117}
5118
5119fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
5120    let message = format!("{error:#}").to_ascii_lowercase();
5121    message.contains("no such table") && message.contains("packages")
5122}
5123
5124fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
5125    for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
5126        row_string(row, 1)
5127    })? {
5128        if value.eq_ignore_ascii_case(column) {
5129            return Ok(true);
5130        }
5131    }
5132
5133    Ok(false)
5134}
5135
5136fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
5137    let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
5138    let id_column = if v2 { "id" } else { "ids.id" };
5139    let name_column = if v2 { "name" } else { "names.name" };
5140    let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
5141    let exact_match = query.exact || semantics == SearchSemantics::Single;
5142    let mut params = Vec::new();
5143
5144    if let Some(value) = &query.id {
5145        return (
5146            single_field_condition(id_column, value, exact_match, &mut params),
5147            params,
5148        );
5149    }
5150    if let Some(value) = &query.name {
5151        return (
5152            single_field_condition(name_column, value, exact_match, &mut params),
5153            params,
5154        );
5155    }
5156    if let Some(value) = &query.moniker {
5157        return (
5158            single_field_condition(moniker_column, value, exact_match, &mut params),
5159            params,
5160        );
5161    }
5162    if let Some(value) = &query.tag {
5163        return (
5164            mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
5165            params,
5166        );
5167    }
5168    if let Some(value) = &query.command {
5169        return (
5170            mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
5171            params,
5172        );
5173    }
5174    if let Some(value) = &query.query {
5175        if exact_match {
5176            let conditions = [
5177                single_field_condition(id_column, value, true, &mut params),
5178                single_field_condition(name_column, value, true, &mut params),
5179                single_field_condition(moniker_column, value, true, &mut params),
5180            ];
5181            return (format!("({})", conditions.join(" OR ")), params);
5182        }
5183
5184        let mut conditions = vec![
5185            single_field_condition(id_column, value, false, &mut params),
5186            single_field_condition(name_column, value, false, &mut params),
5187            single_field_condition(moniker_column, value, false, &mut params),
5188        ];
5189        if semantics == SearchSemantics::Many {
5190            conditions.push(mapped_field_condition(
5191                v2,
5192                "tag",
5193                value,
5194                rowid_column,
5195                false,
5196                &mut params,
5197            ));
5198            conditions.push(mapped_field_condition(
5199                v2,
5200                "command",
5201                value,
5202                rowid_column,
5203                false,
5204                &mut params,
5205            ));
5206        }
5207        return (format!("({})", conditions.join(" OR ")), params);
5208    }
5209
5210    ("1 = 1".to_owned(), Vec::new())
5211}
5212
5213fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
5214    params.push(match_parameter(value, exact));
5215    format!("{column} LIKE ?{}", params.len())
5216}
5217
5218fn mapped_field_condition(
5219    v2: bool,
5220    value_name: &str,
5221    value: &str,
5222    rowid_column: &str,
5223    exact: bool,
5224    params: &mut Vec<String>,
5225) -> String {
5226    let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
5227        (
5228            format!("{value_name}s2"),
5229            format!("{value_name}s2_map"),
5230            value_name.to_owned(),
5231            "package".to_owned(),
5232        )
5233    } else {
5234        (
5235            format!("{value_name}s"),
5236            format!("{value_name}s_map"),
5237            value_name.to_owned(),
5238            "manifest".to_owned(),
5239        )
5240    };
5241    params.push(match_parameter(value, exact));
5242    let parameter = params.len();
5243    format!(
5244        "EXISTS (SELECT 1 FROM {map_table_name} JOIN {table_name} ON \
5245         {map_table_name}.{value_name} = {table_name}.rowid \
5246         WHERE {map_table_name}.{map_owner_column} = {rowid_column} \
5247         AND {table_name}.{map_value_column} LIKE ?{parameter})"
5248    )
5249}
5250
5251fn match_parameter(value: &str, exact: bool) -> String {
5252    if exact { value.to_owned() } else { format!("%{value}%") }
5253}
5254
5255fn build_rest_search_body(
5256    query: &PackageQuery,
5257    info: &RestInformation,
5258    semantics: SearchSemantics,
5259) -> Result<JsonValue> {
5260    let mut root = serde_json::Map::new();
5261    root.insert(
5262        "MaximumResults".to_owned(),
5263        JsonValue::from(source_fetch_results(query, semantics) as u64),
5264    );
5265    let exact_match = query.exact || semantics == SearchSemantics::Single;
5266
5267    if let Some(value) = &query.query {
5268        if semantics == SearchSemantics::Single {
5269            let mut filters = vec![
5270                rest_filter("PackageIdentifier", value, true),
5271                rest_filter("PackageName", value, true),
5272                rest_filter("Moniker", value, true),
5273            ];
5274            append_required_rest_filters(&mut filters, info);
5275            root.insert("Filters".to_owned(), JsonValue::Array(filters));
5276            return Ok(JsonValue::Object(root));
5277        }
5278
5279        root.insert(
5280            "Query".to_owned(),
5281            serde_json::json!({
5282                "KeyWord": value,
5283                "MatchType": if exact_match { "Exact" } else { "Substring" },
5284            }),
5285        );
5286    }
5287
5288    let mut filters = Vec::new();
5289    if let Some(value) = &query.id {
5290        filters.push(rest_filter("PackageIdentifier", value, exact_match));
5291    }
5292    if let Some(value) = &query.name {
5293        filters.push(rest_filter("PackageName", value, exact_match));
5294    }
5295    if let Some(value) = &query.moniker {
5296        filters.push(rest_filter("Moniker", value, exact_match));
5297    }
5298    if let Some(value) = &query.tag {
5299        filters.push(rest_filter("Tag", value, true));
5300    }
5301    if let Some(value) = &query.command {
5302        filters.push(rest_filter("Command", value, true));
5303    }
5304    append_required_rest_filters(&mut filters, info);
5305
5306    if !filters.is_empty() {
5307        root.insert("Filters".to_owned(), JsonValue::Array(filters));
5308    }
5309
5310    Ok(JsonValue::Object(root))
5311}
5312
5313fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
5314    if info
5315        .required_package_match_fields
5316        .iter()
5317        .any(|field| field.eq_ignore_ascii_case("Market"))
5318    {
5319        filters.push(rest_filter("Market", &default_market(), true));
5320    }
5321}
5322
5323fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
5324    serde_json::json!({
5325        "PackageMatchField": field,
5326        "RequestMatch": {
5327            "KeyWord": value,
5328            "MatchType": if exact { "Exact" } else { "Substring" },
5329        }
5330    })
5331}
5332
5333fn max_results(query: &PackageQuery) -> usize {
5334    query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
5335}
5336
5337fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
5338    match semantics {
5339        SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
5340        SearchSemantics::Single => max_results(query),
5341    }
5342}
5343
5344fn infer_preindexed_match_criteria_v2(
5345    connection: &Connection,
5346    row: &V2SearchRow,
5347    query: &PackageQuery,
5348    semantics: SearchSemantics,
5349) -> Result<Option<String>> {
5350    infer_match_criteria(
5351        &row.id,
5352        &row.name,
5353        row.moniker.as_deref(),
5354        query,
5355        semantics,
5356        |value| {
5357            find_mapped_value_v2(
5358                connection,
5359                "tags2",
5360                "tags2_map",
5361                "tag",
5362                row.package_rowid,
5363                value,
5364                query.exact,
5365            )
5366        },
5367        |value| {
5368            find_mapped_value_v2(
5369                connection,
5370                "commands2",
5371                "commands2_map",
5372                "command",
5373                row.package_rowid,
5374                value,
5375                query.exact,
5376            )
5377        },
5378    )
5379}
5380
5381fn infer_preindexed_match_criteria_v1(
5382    connection: &Connection,
5383    row: &V1SearchRow,
5384    query: &PackageQuery,
5385    semantics: SearchSemantics,
5386) -> Result<Option<String>> {
5387    infer_match_criteria(
5388        &row.id,
5389        &row.name,
5390        row.moniker.as_deref(),
5391        query,
5392        semantics,
5393        |value| {
5394            find_mapped_value_v1(
5395                connection,
5396                "tags",
5397                "tags_map",
5398                "tag",
5399                row.manifest_rowid,
5400                value,
5401                query.exact,
5402            )
5403        },
5404        |value| {
5405            find_mapped_value_v1(
5406                connection,
5407                "commands",
5408                "commands_map",
5409                "command",
5410                row.manifest_rowid,
5411                value,
5412                query.exact,
5413            )
5414        },
5415    )
5416}
5417
5418fn infer_match_criteria<FTag, FCommand>(
5419    id: &str,
5420    name: &str,
5421    moniker: Option<&str>,
5422    query: &PackageQuery,
5423    semantics: SearchSemantics,
5424    find_tag: FTag,
5425    find_command: FCommand,
5426) -> Result<Option<String>>
5427where
5428    FTag: FnOnce(&str) -> Result<Option<String>>,
5429    FCommand: FnOnce(&str) -> Result<Option<String>>,
5430{
5431    if let Some(value) = &query.tag {
5432        return Ok(Some(format_match_criteria("Tag", value)));
5433    }
5434    if let Some(value) = &query.command {
5435        return Ok(Some(format_match_criteria("Command", value)));
5436    }
5437    if let Some(value) = &query.moniker {
5438        return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
5439    }
5440    if semantics == SearchSemantics::Single {
5441        return Ok(None);
5442    }
5443    if let Some(value) = &query.query {
5444        if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
5445            return Ok(None);
5446        }
5447        if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
5448            return Ok(Some(format_match_criteria("Moniker", moniker_value)));
5449        }
5450        if let Some(tag) = find_tag(value)? {
5451            return Ok(Some(format_match_criteria("Tag", &tag)));
5452        }
5453        if let Some(command) = find_command(value)? {
5454            return Ok(Some(format_match_criteria("Command", &command)));
5455        }
5456    }
5457    Ok(None)
5458}
5459
5460fn find_mapped_value_v2(
5461    connection: &Connection,
5462    table_name: &str,
5463    map_table_name: &str,
5464    value_name: &str,
5465    package_rowid: i64,
5466    query: &str,
5467    exact: bool,
5468) -> Result<Option<String>> {
5469    let sql = format!(
5470        "SELECT {table_name}.{value_name} FROM {map_table_name} \
5471         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
5472         WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
5473    );
5474    let values = query_rows(
5475        connection,
5476        &sql,
5477        vec![
5478            SqlValue::Integer(package_rowid),
5479            SqlValue::Text(match_parameter(query, exact)),
5480        ],
5481        |row| row_string(row, 0),
5482    )?;
5483    Ok(select_best_text_match(values, query, exact))
5484}
5485
5486fn find_mapped_value_v1(
5487    connection: &Connection,
5488    table_name: &str,
5489    map_table_name: &str,
5490    value_name: &str,
5491    manifest_rowid: i64,
5492    query: &str,
5493    exact: bool,
5494) -> Result<Option<String>> {
5495    let sql = format!(
5496        "SELECT {table_name}.{value_name} FROM {map_table_name} \
5497         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
5498         WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
5499    );
5500    let values = query_rows(
5501        connection,
5502        &sql,
5503        vec![
5504            SqlValue::Integer(manifest_rowid),
5505            SqlValue::Text(match_parameter(query, exact)),
5506        ],
5507        |row| row_string(row, 0),
5508    )?;
5509    Ok(select_best_text_match(values, query, exact))
5510}
5511
5512fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
5513    if let Some(value) = &query.tag {
5514        return Some(format_match_criteria("Tag", value));
5515    }
5516    if let Some(value) = &query.command {
5517        return Some(format_match_criteria("Command", value));
5518    }
5519    if let Some(value) = &query.moniker {
5520        return Some(format_match_criteria("Moniker", value));
5521    }
5522    if semantics == SearchSemantics::Single {
5523        return None;
5524    }
5525    let value = query.query.as_deref()?;
5526    if json_string(item, "PackageIdentifier")
5527        .as_deref()
5528        .is_some_and(|candidate| matches_text(candidate, value, query.exact))
5529        || json_string(item, "PackageName")
5530            .as_deref()
5531            .is_some_and(|candidate| matches_text(candidate, value, query.exact))
5532    {
5533        return None;
5534    }
5535    json_string(item, "Moniker")
5536        .filter(|candidate| matches_text(candidate, value, query.exact))
5537        .map(|candidate| format_match_criteria("Moniker", &candidate))
5538}
5539
5540fn format_match_criteria(field: &str, value: &str) -> String {
5541    format!("{field}: {value}")
5542}
5543
5544fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
5545    if exact {
5546        candidate.eq_ignore_ascii_case(query)
5547    } else {
5548        candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
5549    }
5550}
5551
5552fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
5553    let mut score = 0;
5554
5555    if let Some(value) = &query.query {
5556        score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
5557        score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
5558        if let Some(moniker) = candidate.moniker.as_deref() {
5559            score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
5560        }
5561    }
5562
5563    if let Some(value) = &query.id {
5564        score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
5565    }
5566    if let Some(value) = &query.name {
5567        score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
5568    }
5569    if let Some(value) = &query.moniker
5570        && let Some(moniker) = candidate.moniker.as_deref()
5571    {
5572        score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
5573    }
5574    if let Some(value) = &query.tag
5575        && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
5576    {
5577        score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
5578    }
5579    if let Some(value) = &query.command
5580        && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
5581    {
5582        score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
5583    }
5584
5585    if matches!(candidate.source_kind, SourceKind::PreIndexed) {
5586        score += 5;
5587    }
5588    if search_match_has_unknown_version(candidate) {
5589        score = score.saturating_sub(10);
5590    }
5591
5592    score
5593}
5594
5595fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
5596    candidate
5597        .version
5598        .as_deref()
5599        .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
5600}
5601
5602fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
5603    if manifest_path.is_file() {
5604        return Ok(manifest_path.to_path_buf());
5605    }
5606
5607    if !manifest_path.is_dir() {
5608        bail!("Manifest path not found: {}", manifest_path.display());
5609    }
5610
5611    let candidate = fs::read_dir(manifest_path)?
5612        .filter_map(|entry| entry.ok())
5613        .map(|entry| entry.path())
5614        .filter(|path| {
5615            path.extension()
5616                .and_then(|value| value.to_str())
5617                .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
5618        })
5619        .min();
5620
5621    candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
5622}
5623
5624fn score_text_match(
5625    candidate: &str,
5626    query: &str,
5627    exact: bool,
5628    exact_score: usize,
5629    prefix_score: usize,
5630    substring_score: usize,
5631) -> usize {
5632    if candidate.eq_ignore_ascii_case(query) {
5633        return exact_score;
5634    }
5635    if exact {
5636        return 0;
5637    }
5638
5639    let candidate_lower = candidate.to_ascii_lowercase();
5640    let query_lower = query.to_ascii_lowercase();
5641    if candidate_lower.starts_with(&query_lower) {
5642        prefix_score
5643    } else if candidate_lower.contains(&query_lower) {
5644        substring_score
5645    } else {
5646        0
5647    }
5648}
5649
5650fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
5651where
5652    I: IntoIterator<Item = String>,
5653{
5654    values
5655        .into_iter()
5656        .filter_map(|candidate| {
5657            let score = score_text_match(&candidate, query, exact, 3, 2, 1);
5658            (score > 0).then_some((score, candidate))
5659        })
5660        .max_by(|(left_score, left_value), (right_score, right_value)| {
5661            left_score
5662                .cmp(right_score)
5663                .then_with(|| right_value.len().cmp(&left_value.len()))
5664                .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
5665        })
5666        .map(|(_, candidate)| candidate)
5667}
5668
5669fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
5670    let (field, value) = criteria.split_once(": ")?;
5671    Some((field, value))
5672}
5673
5674fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
5675    let versions = item
5676        .get("Versions")
5677        .and_then(JsonValue::as_array)
5678        .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
5679    let mut result = Vec::new();
5680    for version in versions {
5681        let value = json_string(version, "PackageVersion")
5682            .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
5683        let channel = json_string(version, "Channel").unwrap_or_default();
5684        result.push(VersionKey {
5685            version: value,
5686            channel,
5687        });
5688    }
5689    Ok(result)
5690}
5691
5692fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
5693    let mut merged = YamlMapping::new();
5694    let mut documents = Vec::new();
5695    for document in serde_yaml::Deserializer::from_slice(bytes) {
5696        let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
5697        documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
5698        if let Some(mapping) = value.as_mapping() {
5699            merge_yaml_mapping(&mut merged, mapping);
5700        }
5701    }
5702
5703    let id = yaml_string_from_root(&merged, "PackageIdentifier")
5704        .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
5705    let version =
5706        yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
5707    let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
5708
5709    let installers = parse_yaml_installers(&merged);
5710    let top_level_require_explicit = yaml_scalar_bool(&merged, "RequireExplicitUpgrade");
5711    let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
5712
5713    Ok((
5714        Manifest {
5715            id,
5716            name,
5717            version,
5718            channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
5719            publisher: yaml_localized_string(&merged, "Publisher"),
5720            description: yaml_localized_string(&merged, "Description")
5721                .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
5722            moniker: yaml_string_from_root(&merged, "Moniker"),
5723            package_url: yaml_localized_string(&merged, "PackageUrl"),
5724            publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
5725            publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
5726            license: yaml_localized_string(&merged, "License"),
5727            license_url: yaml_localized_string(&merged, "LicenseUrl"),
5728            privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
5729            author: yaml_localized_string(&merged, "Author"),
5730            copyright: yaml_localized_string(&merged, "Copyright"),
5731            copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
5732            release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
5733            release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
5734            tags: yaml_string_list(&merged, "Tags"),
5735            agreements: yaml_agreement_list(&merged),
5736            package_dependencies: yaml_package_dependencies(&merged),
5737            documentation: yaml_documentation_list(&merged),
5738            installers,
5739            require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
5740        },
5741        collapse_structured_document(&JsonValue::Array(documents)),
5742    ))
5743}
5744
5745fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
5746    parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
5747}
5748
5749fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
5750    let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
5751    let data = root
5752        .get("Data")
5753        .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
5754    let versions = data
5755        .get("Versions")
5756        .and_then(JsonValue::as_array)
5757        .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
5758    let selected = versions
5759        .iter()
5760        .find(|item| {
5761            json_string(item, "PackageVersion").as_deref() == Some(version)
5762                && json_string(item, "Channel").unwrap_or_default() == channel
5763        })
5764        .or_else(|| versions.first())
5765        .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
5766
5767    let default_locale = selected
5768        .get("DefaultLocale")
5769        .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
5770    let name = json_string(default_locale, "PackageName")
5771        .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
5772    let installer_switch_defaults = json_installer_switches(selected);
5773    let top_platforms = json_string_list(selected, "Platform");
5774    let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
5775
5776    let installers = selected
5777        .get("Installers")
5778        .and_then(JsonValue::as_array)
5779        .map(|items| {
5780            items
5781                .iter()
5782                .map(|item| Installer {
5783                    platforms: {
5784                        let platforms = json_string_list(item, "Platform");
5785                        if platforms.is_empty() {
5786                            top_platforms.clone()
5787                        } else {
5788                            platforms
5789                        }
5790                    },
5791                    minimum_os_version: json_string(item, "MinimumOSVersion")
5792                        .or_else(|| top_minimum_os_version.clone()),
5793                    architecture: json_string(item, "Architecture"),
5794                    installer_type: json_string(item, "InstallerType"),
5795                    url: json_string(item, "InstallerUrl"),
5796                    sha256: json_string(item, "InstallerSha256"),
5797                    product_code: json_string(item, "ProductCode"),
5798                    locale: json_string(item, "InstallerLocale"),
5799                    scope: json_string(item, "Scope"),
5800                    release_date: json_string(item, "ReleaseDate"),
5801                    package_family_name: json_string(item, "PackageFamilyName"),
5802                    upgrade_code: json_string(item, "UpgradeCode"),
5803                    switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
5804                    commands: json_string_list(item, "Commands"),
5805                    package_dependencies: json_package_dependencies(item),
5806                    require_explicit_upgrade: json_bool(item, "RequireExplicitUpgrade"),
5807                })
5808                .collect::<Vec<_>>()
5809        })
5810        .unwrap_or_default();
5811    let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade");
5812    let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
5813
5814    let manifest = Manifest {
5815        id: package_id.to_owned(),
5816        name,
5817        version: version.to_owned(),
5818        channel: channel.to_owned(),
5819        publisher: json_string(default_locale, "Publisher"),
5820        description: json_string(default_locale, "Description")
5821            .or_else(|| json_string(default_locale, "ShortDescription")),
5822        moniker: json_string(default_locale, "Moniker"),
5823        package_url: json_string(default_locale, "PackageUrl"),
5824        publisher_url: json_string(default_locale, "PublisherUrl"),
5825        publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
5826        license: json_string(default_locale, "License"),
5827        license_url: json_string(default_locale, "LicenseUrl"),
5828        privacy_url: json_string(default_locale, "PrivacyUrl"),
5829        author: json_string(default_locale, "Author"),
5830        copyright: json_string(default_locale, "Copyright"),
5831        copyright_url: json_string(default_locale, "CopyrightUrl"),
5832        release_notes: json_string(default_locale, "ReleaseNotes"),
5833        release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
5834        tags: json_string_list(default_locale, "Tags"),
5835        agreements: json_agreement_list(default_locale),
5836        package_dependencies: json_package_dependencies(selected),
5837        documentation: json_documentation_list(default_locale),
5838        installers,
5839        require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
5840    };
5841
5842    Ok((
5843        manifest,
5844        build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
5845    ))
5846}
5847
5848fn collapse_structured_document(document: &JsonValue) -> JsonValue {
5849    match document {
5850        JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
5851            merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
5852        }
5853        JsonValue::Array(documents) => merge_manifest_documents(documents),
5854        JsonValue::Object(_) if is_merged_manifest_document(document) => {
5855            merge_manifest_documents(&split_merged_manifest_document(document))
5856        }
5857        _ => document.clone(),
5858    }
5859}
5860
5861fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
5862    documents.iter().map(collapse_structured_document).collect()
5863}
5864
5865fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
5866    let version = documents.iter().find(|document| {
5867        document
5868            .get("ManifestType")
5869            .and_then(JsonValue::as_str)
5870            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
5871    });
5872    let default_locale = documents.iter().find(|document| {
5873        document
5874            .get("ManifestType")
5875            .and_then(JsonValue::as_str)
5876            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
5877    });
5878    let installer = documents.iter().find(|document| {
5879        document
5880            .get("ManifestType")
5881            .and_then(JsonValue::as_str)
5882            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
5883    });
5884
5885    let mut singleton = JsonMap::new();
5886    copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
5887    copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
5888    copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
5889
5890    if !singleton.contains_key("PackageLocale")
5891        && let Some(package_locale) = default_locale
5892            .and_then(|document| json_string(document, "PackageLocale"))
5893            .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
5894    {
5895        singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
5896    }
5897
5898    singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
5899    singleton.insert(
5900        "ManifestVersion".to_owned(),
5901        JsonValue::String(
5902            installer
5903                .and_then(|document| json_string(document, "ManifestVersion"))
5904                .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
5905                .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
5906                .unwrap_or_else(|| "1.10.0".to_owned()),
5907        ),
5908    );
5909    singleton.remove("DefaultLocale");
5910
5911    JsonValue::Object(singleton)
5912}
5913
5914fn is_merged_manifest_document(document: &JsonValue) -> bool {
5915    document
5916        .get("ManifestType")
5917        .and_then(JsonValue::as_str)
5918        .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
5919}
5920
5921fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
5922    let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
5923    let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
5924    let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
5925    let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
5926
5927    let version_document = serde_json::json!({
5928        "PackageIdentifier": package_identifier,
5929        "PackageVersion": package_version,
5930        "DefaultLocale": package_locale,
5931        "ManifestType": "version",
5932        "ManifestVersion": manifest_version,
5933    });
5934
5935    let mut default_locale_document = project_manifest_document(
5936        merged,
5937        &[
5938            "PackageIdentifier",
5939            "PackageVersion",
5940            "PackageLocale",
5941            "Publisher",
5942            "PublisherUrl",
5943            "PublisherSupportUrl",
5944            "PrivacyUrl",
5945            "Author",
5946            "PackageName",
5947            "PackageUrl",
5948            "License",
5949            "LicenseUrl",
5950            "Copyright",
5951            "CopyrightUrl",
5952            "ShortDescription",
5953            "Description",
5954            "Moniker",
5955            "Tags",
5956            "Agreements",
5957            "ReleaseNotes",
5958            "ReleaseNotesUrl",
5959            "PurchaseUrl",
5960            "InstallationNotes",
5961            "Documentations",
5962            "Icons",
5963        ],
5964    );
5965    default_locale_document.insert(
5966        "PackageIdentifier".to_owned(),
5967        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
5968    );
5969    default_locale_document.insert(
5970        "PackageVersion".to_owned(),
5971        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
5972    );
5973    default_locale_document.insert(
5974        "PackageLocale".to_owned(),
5975        JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
5976    );
5977    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
5978    default_locale_document.insert(
5979        "ManifestVersion".to_owned(),
5980        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
5981    );
5982
5983    let mut installer_document = project_manifest_document(
5984        merged,
5985        &[
5986            "PackageIdentifier",
5987            "PackageVersion",
5988            "Channel",
5989            "InstallerLocale",
5990            "Platform",
5991            "MinimumOSVersion",
5992            "InstallerType",
5993            "NestedInstallerType",
5994            "NestedInstallerFiles",
5995            "Scope",
5996            "InstallModes",
5997            "InstallerSwitches",
5998            "InstallerSuccessCodes",
5999            "ExpectedReturnCodes",
6000            "UpgradeBehavior",
6001            "Commands",
6002            "Protocols",
6003            "FileExtensions",
6004            "Dependencies",
6005            "PackageFamilyName",
6006            "ProductCode",
6007            "Capabilities",
6008            "RestrictedCapabilities",
6009            "Markets",
6010            "InstallerAbortsTerminal",
6011            "ReleaseDate",
6012            "InstallLocationRequired",
6013            "RequireExplicitUpgrade",
6014            "DisplayInstallWarnings",
6015            "UnsupportedOSArchitectures",
6016            "UnsupportedArguments",
6017            "AppsAndFeaturesEntries",
6018            "ElevationRequirement",
6019            "InstallationMetadata",
6020            "DownloadCommandProhibited",
6021            "RepairBehavior",
6022            "ArchiveBinariesDependOnPath",
6023            "Authentication",
6024            "Installers",
6025        ],
6026    );
6027    installer_document.insert(
6028        "PackageIdentifier".to_owned(),
6029        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
6030    );
6031    installer_document.insert(
6032        "PackageVersion".to_owned(),
6033        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
6034    );
6035    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
6036    installer_document.insert(
6037        "ManifestVersion".to_owned(),
6038        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
6039    );
6040
6041    vec![
6042        version_document,
6043        JsonValue::Object(default_locale_document),
6044        JsonValue::Object(installer_document),
6045    ]
6046}
6047
6048fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
6049    let mut result = JsonMap::new();
6050    if let Some(source) = source.as_object() {
6051        for key in keys {
6052            if let Some(value) = source.get(*key) {
6053                result.insert((*key).to_owned(), value.clone());
6054            }
6055        }
6056    }
6057
6058    result
6059}
6060
6061fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
6062    if let Some(source) = source.and_then(JsonValue::as_object) {
6063        for key in keys {
6064            if let Some(value) = source.get(*key) {
6065                target.insert((*key).to_owned(), value.clone());
6066            }
6067        }
6068    }
6069}
6070
6071fn copy_all_manifest_keys_except(
6072    target: &mut JsonMap<String, JsonValue>,
6073    source: Option<&JsonValue>,
6074    excluded_keys: &[&str],
6075) {
6076    if let Some(source) = source.and_then(JsonValue::as_object) {
6077        for (key, value) in source {
6078            if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
6079                target.insert(key.clone(), value.clone());
6080            }
6081        }
6082    }
6083}
6084
6085fn build_rest_manifest_documents(
6086    data: &JsonValue,
6087    installer_source: &JsonValue,
6088    default_locale: &JsonValue,
6089    package_id: &str,
6090    version: &str,
6091    channel: &str,
6092) -> JsonValue {
6093    let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
6094    let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
6095    let manifest_version = json_string(installer_source, "ManifestVersion")
6096        .or_else(|| json_string(default_locale, "ManifestVersion"))
6097        .or_else(|| json_string(data, "ManifestVersion"))
6098        .unwrap_or_else(|| "1.10.0".to_owned());
6099
6100    let mut version_document = JsonMap::new();
6101    version_document.insert(
6102        "PackageIdentifier".to_owned(),
6103        JsonValue::String(package_identifier.clone()),
6104    );
6105    version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6106    version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
6107    version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
6108    version_document.insert(
6109        "ManifestVersion".to_owned(),
6110        JsonValue::String(manifest_version.clone()),
6111    );
6112
6113    let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
6114    default_locale_document.insert(
6115        "PackageIdentifier".to_owned(),
6116        JsonValue::String(package_identifier.clone()),
6117    );
6118    default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6119    default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
6120    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
6121    default_locale_document.insert(
6122        "ManifestVersion".to_owned(),
6123        JsonValue::String(manifest_version.clone()),
6124    );
6125
6126    let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
6127    installer_document.remove("DefaultLocale");
6128    installer_document.remove("Locales");
6129    installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
6130    installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6131    if !channel.is_empty() {
6132        installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
6133    }
6134    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
6135    installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
6136
6137    let documents = vec![
6138        JsonValue::Object(version_document),
6139        JsonValue::Object(default_locale_document),
6140        JsonValue::Object(installer_document),
6141    ];
6142
6143    merge_manifest_documents(&documents)
6144}
6145
6146fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
6147    let base = installer_defaults(root);
6148    let base_switches = yaml_installer_switches(root);
6149    if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
6150        let installers = items
6151            .iter()
6152            .filter_map(YamlValue::as_mapping)
6153            .map(|item| {
6154                let mut merged = base.clone();
6155                merge_yaml_mapping(&mut merged, item);
6156                let switches = yaml_installer_switches(item).with_fallback(&base_switches);
6157                installer_from_yaml(&merged, switches)
6158            })
6159            .collect::<Vec<_>>();
6160        if !installers.is_empty() {
6161            return installers;
6162        }
6163    }
6164
6165    if base.is_empty() {
6166        Vec::new()
6167    } else {
6168        vec![installer_from_yaml(&base, base_switches)]
6169    }
6170}
6171
6172fn installer_defaults(root: &YamlMapping) -> YamlMapping {
6173    let keys = [
6174        "Architecture",
6175        "InstallerType",
6176        "InstallerUrl",
6177        "InstallerSha256",
6178        "ProductCode",
6179        "InstallerLocale",
6180        "Platform",
6181        "MinimumOSVersion",
6182        "Scope",
6183        "ReleaseDate",
6184        "PackageFamilyName",
6185        "UpgradeCode",
6186        "Commands",
6187    ];
6188    let mut defaults = YamlMapping::new();
6189    for key in keys {
6190        if let Some(value) = root.get(YamlValue::from(key)) {
6191            defaults.insert(YamlValue::from(key), value.clone());
6192        }
6193    }
6194    defaults
6195}
6196
6197fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
6198    Installer {
6199        architecture: yaml_string(root, "Architecture"),
6200        installer_type: yaml_string(root, "InstallerType"),
6201        url: yaml_string(root, "InstallerUrl"),
6202        sha256: yaml_string(root, "InstallerSha256"),
6203        product_code: yaml_string(root, "ProductCode"),
6204        locale: yaml_string(root, "InstallerLocale"),
6205        scope: yaml_string(root, "Scope"),
6206        release_date: yaml_string(root, "ReleaseDate"),
6207        package_family_name: yaml_string(root, "PackageFamilyName"),
6208        upgrade_code: yaml_string(root, "UpgradeCode"),
6209        platforms: yaml_string_list(root, "Platform"),
6210        minimum_os_version: yaml_string(root, "MinimumOSVersion"),
6211        switches,
6212        commands: yaml_string_list(root, "Commands"),
6213        package_dependencies: yaml_package_dependencies(root),
6214        require_explicit_upgrade: yaml_scalar_bool(root, "RequireExplicitUpgrade"),
6215    }
6216}
6217
6218/// Reads a YAML scalar boolean. Accepts `true`/`false` (any casing) plus
6219/// the legacy `True`/`False` variants the winget catalog mixes in.
6220fn yaml_scalar_bool(root: &YamlMapping, key: &str) -> bool {
6221    let Some(value) = root.get(YamlValue::from(key)) else {
6222        return false;
6223    };
6224    match value {
6225        YamlValue::Bool(b) => *b,
6226        YamlValue::String(s) => s.eq_ignore_ascii_case("true"),
6227        _ => false,
6228    }
6229}
6230
6231fn json_bool(value: &JsonValue, key: &str) -> bool {
6232    match value.get(key) {
6233        Some(JsonValue::Bool(b)) => *b,
6234        Some(JsonValue::String(s)) => s.eq_ignore_ascii_case("true"),
6235        _ => false,
6236    }
6237}
6238
6239fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
6240    let mapping = root
6241        .get(YamlValue::from("InstallerSwitches"))
6242        .or_else(|| root.get(YamlValue::from("Switches")))
6243        .and_then(YamlValue::as_mapping);
6244    yaml_installer_switches_from_mapping(mapping)
6245}
6246
6247fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
6248    let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
6249
6250    InstallerSwitches {
6251        silent: string("Silent"),
6252        silent_with_progress: string("SilentWithProgress"),
6253        interactive: string("Interactive"),
6254        custom: string("Custom"),
6255        log: string("Log"),
6256        install_location: string("InstallLocation"),
6257    }
6258}
6259
6260fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
6261    yaml_string(root, key).or_else(|| {
6262        root.get(YamlValue::from("DefaultLocale"))
6263            .and_then(YamlValue::as_mapping)
6264            .and_then(|mapping| yaml_string(mapping, key))
6265    })
6266}
6267
6268fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
6269    yaml_string(root, key)
6270}
6271
6272fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
6273    root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
6274}
6275
6276fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
6277    match value {
6278        YamlValue::Null => None,
6279        YamlValue::Bool(value) => Some(value.to_string()),
6280        YamlValue::Number(value) => Some(value.to_string()),
6281        YamlValue::String(value) => Some(value.clone()),
6282        YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
6283        YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
6284    }
6285}
6286
6287fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
6288    root.get(YamlValue::from(key))
6289        .and_then(YamlValue::as_sequence)
6290        .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
6291        .unwrap_or_default()
6292}
6293
6294fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
6295    root.get(YamlValue::from("Documentations"))
6296        .and_then(YamlValue::as_sequence)
6297        .map(|items| {
6298            items
6299                .iter()
6300                .filter_map(YamlValue::as_mapping)
6301                .filter_map(|item| {
6302                    let url = yaml_string(item, "DocumentUrl")?;
6303                    Some(Documentation {
6304                        label: yaml_string(item, "DocumentLabel"),
6305                        url,
6306                    })
6307                })
6308                .collect()
6309        })
6310        .unwrap_or_default()
6311}
6312
6313fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
6314    root.get(YamlValue::from("Agreements"))
6315        .and_then(YamlValue::as_sequence)
6316        .map(|items| {
6317            items
6318                .iter()
6319                .filter_map(YamlValue::as_mapping)
6320                .map(|item| PackageAgreement {
6321                    label: yaml_string(item, "AgreementLabel"),
6322                    text: yaml_string(item, "Agreement"),
6323                    url: yaml_string(item, "AgreementUrl"),
6324                })
6325                .collect()
6326        })
6327        .unwrap_or_default()
6328}
6329
6330fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
6331    root.get(YamlValue::from("Dependencies"))
6332        .and_then(YamlValue::as_mapping)
6333        .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
6334        .and_then(YamlValue::as_sequence)
6335        .map(|items| {
6336            items
6337                .iter()
6338                .filter_map(YamlValue::as_mapping)
6339                .filter_map(|item| yaml_string(item, "PackageIdentifier"))
6340                .collect()
6341        })
6342        .unwrap_or_default()
6343}
6344
6345fn json_string(value: &JsonValue, key: &str) -> Option<String> {
6346    value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
6347}
6348
6349fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
6350    value
6351        .get(key)
6352        .and_then(JsonValue::as_array)
6353        .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
6354        .unwrap_or_default()
6355}
6356
6357fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
6358    value
6359        .get("Documentations")
6360        .and_then(JsonValue::as_array)
6361        .map(|items| {
6362            items
6363                .iter()
6364                .filter_map(|item| {
6365                    let url = json_string(item, "DocumentUrl")?;
6366                    Some(Documentation {
6367                        label: json_string(item, "DocumentLabel"),
6368                        url,
6369                    })
6370                })
6371                .collect()
6372        })
6373        .unwrap_or_default()
6374}
6375
6376fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
6377    value
6378        .get("Agreements")
6379        .and_then(JsonValue::as_array)
6380        .map(|items| {
6381            items
6382                .iter()
6383                .map(|item| PackageAgreement {
6384                    label: json_string(item, "AgreementLabel"),
6385                    text: json_string(item, "Agreement"),
6386                    url: json_string(item, "AgreementUrl"),
6387                })
6388                .collect()
6389        })
6390        .unwrap_or_default()
6391}
6392
6393fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
6394    value
6395        .get("Dependencies")
6396        .and_then(JsonValue::as_object)
6397        .and_then(|deps| deps.get("PackageDependencies"))
6398        .and_then(JsonValue::as_array)
6399        .map(|items| {
6400            items
6401                .iter()
6402                .filter_map(|item| json_string(item, "PackageIdentifier"))
6403                .collect()
6404        })
6405        .unwrap_or_default()
6406}
6407
6408fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
6409    let switches = value
6410        .get("InstallerSwitches")
6411        .or_else(|| value.get("Switches"))
6412        .and_then(JsonValue::as_object);
6413
6414    let string = |key: &str| {
6415        switches
6416            .and_then(|switches| switches.get(key))
6417            .and_then(JsonValue::as_str)
6418            .map(str::to_owned)
6419    };
6420
6421    InstallerSwitches {
6422        silent: string("Silent"),
6423        silent_with_progress: string("SilentWithProgress"),
6424        interactive: string("Interactive"),
6425        custom: string("Custom"),
6426        log: string("Log"),
6427        install_location: string("InstallLocation"),
6428    }
6429}
6430
6431fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
6432    for (key, value) in source {
6433        target.insert(key.clone(), value.clone());
6434    }
6435}
6436
6437fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
6438    let reader = Cursor::new(bytes.to_vec());
6439    let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
6440    let mut file = archive
6441        .by_name(member_name)
6442        .with_context(|| format!("zip payload missing {member_name}"))?;
6443    let mut output = Vec::new();
6444    file.read_to_end(&mut output)
6445        .with_context(|| format!("failed to read {member_name} from zip payload"))?;
6446    Ok(output)
6447}
6448
6449fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
6450    if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
6451        return Ok(output);
6452    }
6453
6454    if !bytes.starts_with(b"CK")
6455        && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
6456    {
6457        return decompress_mszyml_payload(&bytes[offset + 2..]);
6458    }
6459
6460    decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
6461}
6462
6463fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
6464    let mut decoder = flate2::read::DeflateDecoder::new(payload);
6465    let mut output = String::new();
6466    decoder.read_to_string(&mut output)?;
6467    Ok(output)
6468}
6469
6470fn cache_control_max_age(response: &Response) -> u64 {
6471    response
6472        .headers()
6473        .get(reqwest::header::CACHE_CONTROL)
6474        .and_then(|value| value.to_str().ok())
6475        .and_then(|header| {
6476            header.split(',').find_map(|part| {
6477                let trimmed = part.trim();
6478                trimmed
6479                    .strip_prefix("max-age=")
6480                    .and_then(|value| value.parse::<u64>().ok())
6481            })
6482        })
6483        .unwrap_or(60)
6484}
6485
6486fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
6487    REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
6488        let (major, minor) = major_minor(candidate);
6489        server_versions.iter().any(|version| {
6490            let (server_major, server_minor) = major_minor(version);
6491            server_major == major && server_minor == minor
6492        })
6493    })
6494}
6495
6496fn major_minor(version: &str) -> (String, String) {
6497    let mut parts = version.split('.');
6498    (
6499        parts.next().unwrap_or_default().to_owned(),
6500        parts.next().unwrap_or_default().to_owned(),
6501    )
6502}
6503
6504fn default_market() -> String {
6505    std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
6506}
6507
6508fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
6509    let requested_locale = query.locale.as_deref();
6510    let requested_architecture = query.installer_architecture.as_deref();
6511    let requested_type = query.installer_type.as_deref();
6512    let requested_scope = query.install_scope.as_deref();
6513    let requested_platform = query.platform.as_deref();
6514    let requested_os_version = query.os_version.as_deref();
6515    let system_architecture = current_architecture();
6516    let system_platform = current_platform();
6517    let current_os_version = current_os_version();
6518
6519    installers
6520        .iter()
6521        .enumerate()
6522        .filter(|(_, installer)| {
6523            installer_matches_requested(
6524                installer,
6525                requested_type,
6526                requested_scope,
6527                requested_platform,
6528                requested_os_version,
6529                system_platform,
6530                current_os_version.as_deref(),
6531            )
6532        })
6533        .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
6534        .max_by_key(|(index, installer)| {
6535            (
6536                installer_rank(installer, requested_locale, requested_architecture, system_architecture),
6537                std::cmp::Reverse(*index),
6538            )
6539        })
6540        .map(|(_, installer)| installer)
6541        .cloned()
6542}
6543
6544fn installer_matches_requested(
6545    installer: &Installer,
6546    requested_type: Option<&str>,
6547    requested_scope: Option<&str>,
6548    requested_platform: Option<&str>,
6549    requested_os_version: Option<&str>,
6550    system_platform: Option<&str>,
6551    current_os_version: Option<&str>,
6552) -> bool {
6553    matches_optional_ci(installer.installer_type.as_deref(), requested_type)
6554        && matches_optional_ci(installer.scope.as_deref(), requested_scope)
6555        && installer_matches_platform(installer, requested_platform, system_platform)
6556        && installer_matches_os_version(installer, requested_os_version, current_os_version)
6557}
6558
6559fn installer_matches_platform(
6560    installer: &Installer,
6561    requested_platform: Option<&str>,
6562    system_platform: Option<&str>,
6563) -> bool {
6564    if installer.platforms.is_empty() {
6565        return true;
6566    }
6567
6568    let Some(effective_platform) = requested_platform.or(system_platform) else {
6569        return true;
6570    };
6571
6572    installer
6573        .platforms
6574        .iter()
6575        .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
6576}
6577
6578fn installer_matches_os_version(
6579    installer: &Installer,
6580    requested_os_version: Option<&str>,
6581    current_os_version: Option<&str>,
6582) -> bool {
6583    let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
6584        return true;
6585    };
6586
6587    let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
6588        return true;
6589    };
6590
6591    match (
6592        parse_dotted_version(minimum_os_version),
6593        parse_dotted_version(actual_os_version),
6594    ) {
6595        (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
6596        _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
6597    }
6598}
6599
6600fn installer_matches_architecture(
6601    installer: &Installer,
6602    requested_architecture: Option<&str>,
6603    system_architecture: &str,
6604) -> bool {
6605    let Some(architecture) = installer.architecture.as_deref() else {
6606        return true;
6607    };
6608
6609    let architecture = architecture.to_ascii_lowercase();
6610    if let Some(requested) = requested_architecture {
6611        return architecture.eq_ignore_ascii_case(requested);
6612    }
6613
6614    preferred_architectures(system_architecture)
6615        .iter()
6616        .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
6617}
6618
6619fn installer_rank(
6620    installer: &Installer,
6621    requested_locale: Option<&str>,
6622    requested_architecture: Option<&str>,
6623    system_architecture: &str,
6624) -> (i32, i32, i32) {
6625    let architecture_rank = architecture_rank(
6626        installer.architecture.as_deref(),
6627        requested_architecture.unwrap_or(system_architecture),
6628        requested_architecture.is_some(),
6629        system_architecture,
6630    );
6631    let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
6632    let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
6633    (architecture_rank, locale_rank, command_rank)
6634}
6635
6636fn architecture_rank(
6637    installer_architecture: Option<&str>,
6638    preferred_architecture: &str,
6639    strict: bool,
6640    system_architecture: &str,
6641) -> i32 {
6642    let Some(value) = installer_architecture else {
6643        return 0;
6644    };
6645    if value.eq_ignore_ascii_case(preferred_architecture) {
6646        return 5;
6647    }
6648    if value.eq_ignore_ascii_case("neutral") {
6649        return 4;
6650    }
6651    if strict {
6652        return -1;
6653    }
6654
6655    preferred_architectures(system_architecture)
6656        .iter()
6657        .rev()
6658        .position(|candidate| value.eq_ignore_ascii_case(candidate))
6659        .and_then(|index| i32::try_from(index).ok())
6660        .and_then(|index| index.checked_add(1))
6661        .unwrap_or(-1)
6662}
6663
6664/// True when at least one installer in `manifest.installers` targets an
6665/// architecture the host can run. winget hides upgrades whose latest
6666/// version publishes no compatible installer.
6667fn manifest_has_compatible_installer(manifest: &Manifest) -> bool {
6668    if manifest.installers.is_empty() {
6669        return true;
6670    }
6671    let allowed = preferred_architectures(current_architecture());
6672    manifest.installers.iter().any(|installer| {
6673        let arch = installer.architecture.as_deref().unwrap_or("neutral");
6674        allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch))
6675    })
6676}
6677
6678fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
6679    match system_architecture {
6680        "arm64" => &["arm64", "neutral", "x64", "x86"],
6681        "x64" => &["x64", "neutral", "x86"],
6682        "x86" => &["x86", "neutral"],
6683        _ => &["neutral"],
6684    }
6685}
6686
6687fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
6688    match (installer_locale, requested_locale) {
6689        (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
6690        (Some(installer), Some(requested))
6691            if installer
6692                .split('-')
6693                .next()
6694                .zip(requested.split('-').next())
6695                .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
6696        {
6697            2
6698        }
6699        (None, Some(_)) => 1,
6700        (Some(_), None) => 1,
6701        (None, None) => 0,
6702        _ => -1,
6703    }
6704}
6705
6706fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
6707    match requested {
6708        Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
6709        None => true,
6710    }
6711}
6712
6713#[cfg(windows)]
6714fn current_architecture() -> &'static str {
6715    use std::sync::OnceLock;
6716    static CELL: OnceLock<&'static str> = OnceLock::new();
6717    CELL.get_or_init(|| {
6718        // IsWow64Process2's `native_machine` is the OS architecture even
6719        // when the process is running under emulation (x64 binary on
6720        // arm64 Windows, x86 on x64). Compile-time `consts::ARCH` would
6721        // mis-report the host arch in that case.
6722        use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2};
6723        let mut process_machine: u16 = 0;
6724        let mut native_machine: u16 = 0;
6725        // SAFETY: GetCurrentProcess returns a pseudo-handle that never fails;
6726        // both out-pointers reference local stack u16s that outlive the call.
6727        let handle = unsafe { GetCurrentProcess() };
6728        // SAFETY: handle is valid per above; pointers are valid.
6729        let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) };
6730        if ok != 0 {
6731            match native_machine {
6732                0xAA64 => return "arm64",
6733                0x8664 => return "x64",
6734                0x014C => return "x86",
6735                _ => {}
6736            }
6737        }
6738        compiled_architecture()
6739    })
6740}
6741
6742#[cfg(not(windows))]
6743fn current_architecture() -> &'static str {
6744    compiled_architecture()
6745}
6746
6747fn compiled_architecture() -> &'static str {
6748    match std::env::consts::ARCH {
6749        "x86_64" => "x64",
6750        "x86" => "x86",
6751        "aarch64" => "arm64",
6752        _ => "neutral",
6753    }
6754}
6755
6756fn current_platform() -> Option<&'static str> {
6757    if cfg!(windows) { Some("Windows.Desktop") } else { None }
6758}
6759
6760#[cfg(windows)]
6761fn current_os_version() -> Option<String> {
6762    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
6763    let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
6764    let build: String = key.get_value("CurrentBuildNumber").ok()?;
6765    let ubr: u32 = key.get_value("UBR").unwrap_or(0);
6766    let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
6767    let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
6768    Some(format!("{major}.{minor}.{build}.{ubr}"))
6769}
6770
6771#[cfg(not(windows))]
6772fn current_os_version() -> Option<String> {
6773    None
6774}
6775
6776fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
6777    let mut parsed = Vec::new();
6778    for component in version.split('.') {
6779        if component.is_empty() {
6780            continue;
6781        }
6782        parsed.push(component.parse().ok()?);
6783    }
6784
6785    if parsed.is_empty() { None } else { Some(parsed) }
6786}
6787
6788fn select_v1_version(
6789    versions: &[V1VersionRow],
6790    requested_version: Option<&str>,
6791    requested_channel: Option<&str>,
6792) -> Result<V1VersionRow> {
6793    let mut ordered = versions.to_vec();
6794    ordered.sort_by(|left, right| {
6795        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
6796    });
6797
6798    if let Some(version) = requested_version {
6799        let channel = requested_channel.unwrap_or_default();
6800        return ordered
6801            .iter()
6802            .find(|candidate| {
6803                candidate.version.eq_ignore_ascii_case(version)
6804                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
6805            })
6806            .cloned()
6807            .ok_or_else(|| anyhow!("requested version {version} was not found"));
6808    }
6809
6810    ordered
6811        .into_iter()
6812        .next()
6813        .ok_or_else(|| anyhow!("package had no versions"))
6814}
6815
6816fn select_v2_version(
6817    versions: &[PackageVersionDataEntry],
6818    requested_version: Option<&str>,
6819) -> Result<PackageVersionDataEntry> {
6820    let mut ordered = versions.to_vec();
6821    ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
6822
6823    if let Some(version) = requested_version {
6824        return ordered
6825            .iter()
6826            .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
6827            .cloned()
6828            .ok_or_else(|| anyhow!("requested version {version} was not found"));
6829    }
6830
6831    ordered
6832        .into_iter()
6833        .next()
6834        .ok_or_else(|| anyhow!("package had no V2 versions"))
6835}
6836
6837fn select_rest_version<'a>(
6838    versions: &'a [VersionKey],
6839    requested_version: Option<&str>,
6840    requested_channel: Option<&str>,
6841) -> Result<&'a VersionKey> {
6842    if let Some(version) = requested_version {
6843        let channel = requested_channel.unwrap_or_default();
6844        return versions
6845            .iter()
6846            .find(|candidate| {
6847                candidate.version.eq_ignore_ascii_case(version)
6848                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
6849            })
6850            .ok_or_else(|| anyhow!("requested version {version} was not found"));
6851    }
6852
6853    versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
6854}
6855
6856fn sort_versions_desc(versions: &mut [VersionKey]) {
6857    versions.sort_by(|left, right| {
6858        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
6859    });
6860}
6861
6862fn compare_version_and_channel(
6863    left_version: &str,
6864    left_channel: &str,
6865    right_version: &str,
6866    right_channel: &str,
6867) -> Ordering {
6868    compare_version(left_version, right_version).then_with(|| {
6869        left_channel
6870            .to_ascii_lowercase()
6871            .cmp(&right_channel.to_ascii_lowercase())
6872    })
6873}
6874
6875fn compare_version(left: &str, right: &str) -> Ordering {
6876    let left_parts = tokenize_version(left);
6877    let right_parts = tokenize_version(right);
6878    let max_len = left_parts.len().max(right_parts.len());
6879
6880    for index in 0..max_len {
6881        let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
6882        let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
6883        let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
6884
6885        let ordering = if let Some((left_number, right_number)) = numeric {
6886            left_number.cmp(&right_number)
6887        } else {
6888            left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
6889        };
6890
6891        if ordering != Ordering::Equal {
6892            return ordering;
6893        }
6894    }
6895
6896    Ordering::Equal
6897}
6898
6899fn tokenize_version(value: &str) -> Vec<String> {
6900    let mut parts = Vec::new();
6901    let mut current = String::new();
6902    let mut current_is_digit = None;
6903
6904    for ch in value.chars() {
6905        if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
6906            if !current.is_empty() {
6907                parts.push(std::mem::take(&mut current));
6908            }
6909            current_is_digit = None;
6910            continue;
6911        }
6912
6913        let is_digit = ch.is_ascii_digit();
6914        match current_is_digit {
6915            Some(previous) if previous != is_digit => {
6916                parts.push(std::mem::take(&mut current));
6917                current.push(ch);
6918                current_is_digit = Some(is_digit);
6919            }
6920            Some(_) => current.push(ch),
6921            None => {
6922                current.push(ch);
6923                current_is_digit = Some(is_digit);
6924            }
6925        }
6926    }
6927
6928    if !current.is_empty() {
6929        parts.push(current);
6930    }
6931
6932    parts
6933}
6934
6935fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
6936    if !hash_matches(Some(expected_hash), bytes) {
6937        bail!("downloaded file hash did not match expected SHA256");
6938    }
6939    Ok(())
6940}
6941
6942fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
6943    let Some(expected_hash) = expected_hash else {
6944        return Ok(());
6945    };
6946    if ignore_security_hash {
6947        return Ok(());
6948    }
6949
6950    if !hash_matches(Some(expected_hash), bytes) {
6951        let actual = sha256_hex(bytes);
6952        bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
6953    }
6954    Ok(())
6955}
6956
6957fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
6958    expected_hash
6959        .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
6960        .unwrap_or(true)
6961}
6962
6963fn sha256_hex(bytes: &[u8]) -> String {
6964    let digest = Sha256::digest(bytes);
6965    let mut output = String::with_capacity(digest.len() * 2);
6966    for byte in digest {
6967        output.push_str(&format!("{byte:02X}"));
6968    }
6969    output
6970}
6971
6972fn pins_db_path(app_root: &Path) -> PathBuf {
6973    if uses_packaged_layout(app_root) {
6974        app_root.join("pinning.db")
6975    } else {
6976        app_root.join("pins.db")
6977    }
6978}
6979
6980fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
6981    let mut stmt = conn.prepare("PRAGMA table_info(pin)")?;
6982    let mut has_current_column = false;
6983    let mut has_legacy_column = false;
6984
6985    let column_names = stmt.query_map([], |row| row.get::<_, String>(1))?;
6986    for column_name in column_names {
6987        match column_name?.as_str() {
6988            "type" => has_current_column = true,
6989            "pin_type" => has_legacy_column = true,
6990            _ => {}
6991        }
6992    }
6993
6994    Ok(if has_current_column {
6995        Some("type")
6996    } else if has_legacy_column {
6997        Some("pin_type")
6998    } else {
6999        None
7000    })
7001}
7002
7003fn decode_pin_type(pin_type_int: i64) -> PinType {
7004    match pin_type_int {
7005        4 => PinType::Blocking,
7006        3 => PinType::Gating,
7007        _ => PinType::Pinning,
7008    }
7009}
7010
7011#[cfg(windows)]
7012fn dispatch_installer(
7013    installer_path: &Path,
7014    installer_type: &str,
7015    request: &InstallRequest,
7016    manifest: &Manifest,
7017    installer: &Installer,
7018) -> Result<i32> {
7019    use std::process::Command;
7020
7021    match installer_type {
7022        "msi" | "wix" => {
7023            let mut cmd = Command::new("msiexec");
7024            cmd.args(installer_command_arguments(
7025                installer_type,
7026                request,
7027                manifest,
7028                installer_path,
7029                installer,
7030            ));
7031            let status = cmd.status().context("failed to run msiexec")?;
7032            Ok(status.code().unwrap_or(-1))
7033        }
7034        "msix" | "appx" => {
7035            let mut cmd = Command::new("powershell");
7036            cmd.arg("-NoProfile")
7037                .arg("-Command")
7038                .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
7039            let status = cmd.status().context("failed to run Add-AppxPackage")?;
7040            Ok(status.code().unwrap_or(-1))
7041        }
7042        "zip" => {
7043            let target = dirs::data_local_dir()
7044                .unwrap_or_else(|| PathBuf::from("."))
7045                .join("Programs");
7046            fs::create_dir_all(&target)?;
7047            let file = fs::File::open(installer_path)?;
7048            let mut archive = ZipArchive::new(file)?;
7049            archive.extract(&target)?;
7050            Ok(0)
7051        }
7052        // exe, inno, nullsoft, burn, etc.
7053        _ => {
7054            let mut cmd = Command::new(installer_path);
7055            cmd.args(installer_command_arguments(
7056                installer_type,
7057                request,
7058                manifest,
7059                installer_path,
7060                installer,
7061            ));
7062            let status = cmd.status().context("failed to run installer")?;
7063            Ok(status.code().unwrap_or(-1))
7064        }
7065    }
7066}
7067
7068#[cfg(not(windows))]
7069fn dispatch_installer(
7070    _installer_path: &Path,
7071    _installer_type: &str,
7072    _request: &InstallRequest,
7073    _manifest: &Manifest,
7074    _installer: &Installer,
7075) -> Result<i32> {
7076    bail!("Installing packages is only supported on Windows")
7077}
7078
7079#[cfg(any(windows, test))]
7080fn installer_command_arguments(
7081    installer_type: &str,
7082    request: &InstallRequest,
7083    _manifest: &Manifest,
7084    installer_path: &Path,
7085    installer: &Installer,
7086) -> Vec<String> {
7087    if let Some(override_args) = request.override_args.as_deref() {
7088        return split_installer_switches(override_args);
7089    }
7090
7091    let mut args = Vec::new();
7092    match installer_type {
7093        "msi" | "wix" => {
7094            args.push("/i".to_owned());
7095            args.push(installer_path.display().to_string());
7096        }
7097        "msix" | "appx" | "zip" => return Vec::new(),
7098        _ => {}
7099    }
7100
7101    let configured = match request.mode {
7102        InstallerMode::Interactive => installer.switches.interactive.as_deref(),
7103        InstallerMode::SilentWithProgress => installer
7104            .switches
7105            .silent_with_progress
7106            .as_deref()
7107            .or(installer.switches.silent.as_deref()),
7108        InstallerMode::Silent => installer
7109            .switches
7110            .silent
7111            .as_deref()
7112            .or(installer.switches.silent_with_progress.as_deref()),
7113    };
7114
7115    if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
7116        args.extend(split_installer_switches(value));
7117    } else {
7118        args.extend(default_installer_arguments(installer_type, request.mode));
7119    }
7120
7121    append_switches(
7122        &mut args,
7123        resolve_template_switch(
7124            installer.switches.log.as_deref(),
7125            default_log_switch(installer_type),
7126            request.log_path.as_ref().map(|path| path.display().to_string()),
7127            "<LOGPATH>",
7128        ),
7129    );
7130    append_switches(&mut args, installer.switches.custom.clone());
7131    append_switches(&mut args, request.custom.clone());
7132    append_switches(
7133        &mut args,
7134        resolve_template_switch(
7135            installer.switches.install_location.as_deref(),
7136            default_install_location_switch(installer_type),
7137            request.install_location.clone(),
7138            "<INSTALLPATH>",
7139        ),
7140    );
7141    args
7142}
7143
7144#[cfg(any(windows, test))]
7145fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
7146    match mode {
7147        InstallerMode::Interactive => Vec::new(),
7148        InstallerMode::SilentWithProgress => match installer_type {
7149            "inno" => vec![
7150                "/SP-".to_owned(),
7151                "/SILENT".to_owned(),
7152                "/SUPPRESSMSGBOXES".to_owned(),
7153                "/NORESTART".to_owned(),
7154            ],
7155            "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
7156            "nullsoft" | "nsis" => vec!["/S".to_owned()],
7157            _ => vec!["/SILENT".to_owned()],
7158        },
7159        InstallerMode::Silent => match installer_type {
7160            "inno" => vec![
7161                "/SP-".to_owned(),
7162                "/VERYSILENT".to_owned(),
7163                "/SUPPRESSMSGBOXES".to_owned(),
7164                "/NORESTART".to_owned(),
7165            ],
7166            "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
7167            "nullsoft" | "nsis" => vec!["/S".to_owned()],
7168            _ => vec!["/S".to_owned()],
7169        },
7170    }
7171}
7172
7173#[cfg(any(windows, test))]
7174fn default_log_switch(installer_type: &str) -> Option<&'static str> {
7175    match installer_type {
7176        "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
7177        "inno" => Some("/LOG=\"<LOGPATH>\""),
7178        _ => None,
7179    }
7180}
7181
7182#[cfg(any(windows, test))]
7183fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
7184    match installer_type {
7185        "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
7186        "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
7187        "inno" => Some("/DIR=\"<INSTALLPATH>\""),
7188        _ => None,
7189    }
7190}
7191
7192#[cfg(any(windows, test))]
7193fn resolve_template_switch(
7194    manifest_value: Option<&str>,
7195    fallback: Option<&str>,
7196    replacement: Option<String>,
7197    token: &str,
7198) -> Option<String> {
7199    let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
7200    let replacement = replacement?;
7201    Some(template.replace(token, &replacement))
7202}
7203
7204#[cfg(any(windows, test))]
7205fn append_switches(args: &mut Vec<String>, value: Option<String>) {
7206    if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
7207        args.extend(split_installer_switches(&value));
7208    }
7209}
7210
7211#[cfg(any(windows, test))]
7212fn split_installer_switches(value: &str) -> Vec<String> {
7213    let mut args = Vec::new();
7214    let mut current = String::new();
7215    let mut in_quotes = false;
7216
7217    for ch in value.chars() {
7218        match ch {
7219            '"' => in_quotes = !in_quotes,
7220            c if c.is_whitespace() && !in_quotes => {
7221                if !current.is_empty() {
7222                    args.push(std::mem::take(&mut current));
7223                }
7224            }
7225            _ => current.push(ch),
7226        }
7227    }
7228
7229    if !current.is_empty() {
7230        args.push(current);
7231    }
7232
7233    args
7234}
7235
7236#[cfg(windows)]
7237fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
7238    if request.purge && request.preserve {
7239        bail!("--purge and --preserve cannot be used together.");
7240    }
7241
7242    if installed
7243        .installer_category
7244        .as_deref()
7245        .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
7246    {
7247        return uninstall_portable(installed, request);
7248    }
7249
7250    if (request.purge || request.preserve) && !request.force {
7251        bail!("--purge and --preserve are currently only supported for portable packages.");
7252    }
7253
7254    if let Some(exit_code) = try_uninstall_arp(installed, request)? {
7255        return Ok(exit_code);
7256    }
7257
7258    if let Some(exit_code) = try_uninstall_msix(installed)? {
7259        return Ok(exit_code);
7260    }
7261
7262    bail!(
7263        "No uninstall command found for installed package '{}' ({})",
7264        installed.name,
7265        installed.local_id
7266    );
7267}
7268
7269#[cfg(windows)]
7270fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
7271    use std::os::windows::process::CommandExt;
7272    use std::process::Command;
7273
7274    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
7275    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
7276
7277    let arp_paths = [
7278        r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
7279        r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
7280    ];
7281
7282    for root in [&hklm, &hkcu] {
7283        for arp_path in &arp_paths {
7284            if let Ok(key) = root.open_subkey(arp_path) {
7285                for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
7286                    if let Ok(subkey) = key.open_subkey(&subkey_name) {
7287                        let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
7288                        let product_code = subkey.get_value::<String, _>("ProductCode").ok();
7289                        if !registry_entry_matches_installed_package(
7290                            &subkey_name,
7291                            &display_name,
7292                            product_code.as_deref(),
7293                            installed,
7294                        ) {
7295                            continue;
7296                        }
7297
7298                        if let Some(exit_code) =
7299                            try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
7300                        {
7301                            return Ok(Some(exit_code));
7302                        }
7303
7304                        let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
7305                        let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
7306                            subkey.get_value("UninstallString").ok()
7307                        } else {
7308                            quiet_uninstall_cmd
7309                                .clone()
7310                                .or_else(|| subkey.get_value("UninstallString").ok())
7311                        }
7312                        .context("No uninstall command found in registry")?;
7313
7314                        if is_winget_uninstall_command(&uninstall_cmd)
7315                            && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
7316                        {
7317                            return Ok(Some(exit_code));
7318                        }
7319
7320                        let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
7321                        let uninstall_line = build_uninstall_command_with_mode(
7322                            &uninstall_cmd,
7323                            request.mode,
7324                            quiet_uninstall_cmd.is_some(),
7325                            log_path.as_deref(),
7326                        );
7327                        // `UninstallString` is a complete command line and may already contain
7328                        // its own quotes (e.g. `"C:\path with spaces\unins.exe"`). Passing it
7329                        // through Command::arg causes Rust's CRT-style quoting to backslash-escape
7330                        // those embedded quotes (`\"...\"`), which cmd.exe does not understand and
7331                        // rejects with "is not recognized as an internal or external command".
7332                        // Use `cmd /S /C "<line>"`: `/S` makes cmd strip only the outermost pair
7333                        // of quotes, leaving the embedded quotes intact. `raw_arg` bypasses Rust's
7334                        // automatic quoting so we can feed cmd the literal command line.
7335                        let mut cmd = Command::new("cmd");
7336                        cmd.raw_arg("/S /C");
7337                        cmd.raw_arg(format!("\"{uninstall_line}\""));
7338                        let status = cmd.status().context("failed to run uninstaller")?;
7339                        return Ok(Some(status.code().unwrap_or(-1)));
7340                    }
7341                }
7342            }
7343        }
7344    }
7345
7346    Ok(None)
7347}
7348
7349#[cfg(windows)]
7350fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
7351    use std::process::Command;
7352
7353    if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
7354        return Ok(None);
7355    }
7356
7357    let package_full_name = installed.local_id.strip_prefix("MSIX\\");
7358    let mut cmd = Command::new("powershell");
7359    cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
7360        package_full_name,
7361        &installed.package_family_names,
7362    ));
7363    let status = cmd.status().context("failed to run Remove-AppxPackage")?;
7364    Ok(Some(status.code().unwrap_or(-1)))
7365}
7366
7367#[cfg(windows)]
7368fn arp_subkey_name(local_id: &str) -> Option<&str> {
7369    local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
7370}
7371
7372#[cfg(windows)]
7373fn registry_entry_matches_installed_package(
7374    subkey_name: &str,
7375    display_name: &str,
7376    product_code: Option<&str>,
7377    installed: &ListMatch,
7378) -> bool {
7379    if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
7380        && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
7381    {
7382        return true;
7383    }
7384
7385    if installed.product_codes.iter().any(|code| {
7386        code.eq_ignore_ascii_case(subkey_name)
7387            || product_code
7388                .map(|value| code.eq_ignore_ascii_case(value))
7389                .unwrap_or(false)
7390    }) {
7391        return true;
7392    }
7393
7394    display_name.eq_ignore_ascii_case(&installed.name)
7395}
7396
7397#[cfg(all(windows, test))]
7398fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
7399    build_uninstall_command_with_mode(
7400        uninstall_cmd,
7401        if silent {
7402            InstallerMode::Silent
7403        } else {
7404            InstallerMode::Interactive
7405        },
7406        has_quiet_command,
7407        None,
7408    )
7409}
7410
7411#[cfg(windows)]
7412fn build_uninstall_command_with_mode(
7413    uninstall_cmd: &str,
7414    mode: InstallerMode,
7415    has_quiet_command: bool,
7416    log_path: Option<&str>,
7417) -> String {
7418    let uninstall_cmd = log_path
7419        .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
7420        .unwrap_or_else(|| uninstall_cmd.to_owned());
7421    if mode == InstallerMode::Interactive || has_quiet_command {
7422        return uninstall_cmd;
7423    }
7424
7425    let lower = uninstall_cmd.to_ascii_lowercase();
7426    if is_winget_uninstall_command_lower(&lower) {
7427        return uninstall_cmd;
7428    }
7429    if lower.contains("/quiet")
7430        || lower.contains("/passive")
7431        || lower.contains("/verysilent")
7432        || lower.contains("/silent")
7433        || lower.contains(" /s")
7434    {
7435        return uninstall_cmd;
7436    }
7437
7438    format!("{uninstall_cmd} /S")
7439}
7440
7441#[cfg(windows)]
7442fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
7443    let Some(args) = build_winget_uninstall_arguments(installed, request) else {
7444        return Ok(None);
7445    };
7446
7447    let exit_code = run_winget_uninstall(&args)?;
7448    if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
7449        let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
7450            return Ok(Some(exit_code));
7451        };
7452
7453        return Ok(Some(run_winget_uninstall(&args_without_scope)?));
7454    }
7455
7456    Ok(Some(exit_code))
7457}
7458
7459#[cfg(windows)]
7460fn run_winget_uninstall(args: &[String]) -> Result<i32> {
7461    use std::process::Command;
7462
7463    let status = Command::new("winget")
7464        .args(args)
7465        .status()
7466        .context("failed to run delegated winget uninstall")?;
7467    Ok(status.code().unwrap_or(-1))
7468}
7469
7470#[cfg(windows)]
7471fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
7472    build_winget_uninstall_arguments_with_scope(installed, request, true)
7473}
7474
7475#[cfg(windows)]
7476fn build_winget_uninstall_arguments_with_scope(
7477    installed: &ListMatch,
7478    request: &UninstallRequest,
7479    include_scope: bool,
7480) -> Option<Vec<String>> {
7481    if installed.id.eq_ignore_ascii_case(&installed.local_id) {
7482        return None;
7483    }
7484
7485    let mut args = vec![
7486        "uninstall".to_owned(),
7487        "--id".to_owned(),
7488        installed.id.clone(),
7489        "--exact".to_owned(),
7490        "--disable-interactivity".to_owned(),
7491    ];
7492
7493    if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
7494        args.push("--source".to_owned());
7495        args.push(source_name.to_owned());
7496    }
7497
7498    if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
7499        args.push("--scope".to_owned());
7500        args.push(scope.to_owned());
7501    }
7502
7503    if request.mode == InstallerMode::Silent {
7504        args.push("--silent".to_owned());
7505    }
7506
7507    Some(args)
7508}
7509
7510#[cfg(windows)]
7511fn is_winget_uninstall_command(command: &str) -> bool {
7512    is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
7513}
7514
7515#[cfg(windows)]
7516fn is_winget_uninstall_command_lower(command: &str) -> bool {
7517    command.contains("winget uninstall") || command.contains("winget.exe uninstall")
7518}
7519
7520#[cfg(windows)]
7521fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
7522    let full_name_literal = package_full_name
7523        .map(|value| format!("'{}'", value.replace('\'', "''")))
7524        .unwrap_or_else(|| "$null".to_owned());
7525    let family_names_literal = if package_family_names.is_empty() {
7526        "@()".to_owned()
7527    } else {
7528        format!(
7529            "@({})",
7530            package_family_names
7531                .iter()
7532                .map(|value| format!("'{}'", value.replace('\'', "''")))
7533                .collect::<Vec<_>>()
7534                .join(", ")
7535        )
7536    };
7537
7538    format!(
7539        "$fullName = {full_name_literal}; \
7540         $familyNames = {family_names_literal}; \
7541         $targets = Get-AppxPackage | Where-Object {{ \
7542         (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
7543         ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
7544         if (-not $targets) {{ exit 1 }}; \
7545         $targets | Remove-AppxPackage"
7546    )
7547}
7548
7549#[cfg(windows)]
7550fn try_run_msi_uninstall(
7551    installed: &ListMatch,
7552    subkey_name: &str,
7553    product_code: Option<&str>,
7554    request: &UninstallRequest,
7555) -> Result<Option<i32>> {
7556    use std::process::Command;
7557
7558    let uninstall_code = installed
7559        .product_codes
7560        .iter()
7561        .map(String::as_str)
7562        .chain(product_code)
7563        .chain(Some(subkey_name))
7564        .find(|value| is_product_code_like(value));
7565
7566    let Some(uninstall_code) = uninstall_code else {
7567        return Ok(None);
7568    };
7569
7570    let mut cmd = Command::new("msiexec");
7571    cmd.arg("/x").arg(uninstall_code);
7572    match request.mode {
7573        InstallerMode::Interactive => {}
7574        InstallerMode::SilentWithProgress => {
7575            cmd.arg("/passive").arg("/norestart");
7576        }
7577        InstallerMode::Silent => {
7578            cmd.arg("/quiet").arg("/norestart");
7579        }
7580    }
7581    if let Some(log_path) = &request.log_path {
7582        cmd.arg("/log").arg(log_path);
7583    }
7584
7585    let status = cmd.status().context("failed to run msiexec uninstall")?;
7586    Ok(Some(status.code().unwrap_or(-1)))
7587}
7588
7589#[cfg(windows)]
7590fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
7591    let Some(location) = installed.install_location.as_deref() else {
7592        if request.force {
7593            return Ok(0);
7594        }
7595        bail!(
7596            "Portable package '{}' does not expose an install location.",
7597            installed.name
7598        );
7599    };
7600
7601    if request.preserve {
7602        return Ok(0);
7603    }
7604
7605    let path = Path::new(location);
7606    if path.is_dir() {
7607        fs::remove_dir_all(path)?;
7608        return Ok(0);
7609    }
7610    if path.is_file() {
7611        fs::remove_file(path)?;
7612        return Ok(0);
7613    }
7614    if request.force {
7615        return Ok(0);
7616    }
7617
7618    bail!("Portable package location not found: {location}")
7619}
7620
7621#[cfg(windows)]
7622fn is_product_code_like(value: &str) -> bool {
7623    value.starts_with('{') && value.ends_with('}')
7624}
7625
7626#[cfg(not(windows))]
7627fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
7628    bail!("Uninstalling packages is only supported on Windows")
7629}
7630
7631#[cfg(test)]
7632mod tests {
7633    use std::io::Write;
7634    use std::path::{Path, PathBuf};
7635
7636    use flate2::Compression;
7637    use flate2::write::DeflateEncoder;
7638
7639    use super::*;
7640
7641    fn temp_app_root(label: &str) -> PathBuf {
7642        std::env::temp_dir().join(format!(
7643            "pinget-rs-tests-{label}-{}",
7644            Utc::now().timestamp_nanos_opt().unwrap_or_default()
7645        ))
7646    }
7647
7648    #[test]
7649    fn repository_options_capture_custom_host_settings() {
7650        let options =
7651            RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test")).with_user_agent("pinget-rs-tests/1.0");
7652
7653        assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
7654        assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
7655    }
7656
7657    #[test]
7658    fn dependency_install_requests_are_silent_and_not_forced() {
7659        let mut parent = InstallRequest::new(PackageQuery {
7660            source: Some("winget".to_owned()),
7661            ..PackageQuery::default()
7662        });
7663        parent.mode = InstallerMode::SilentWithProgress;
7664        parent.force = true;
7665        parent.accept_package_agreements = true;
7666        parent.ignore_security_hash = true;
7667        parent.dependency_source = Some("dependencies".to_owned());
7668
7669        let dependency =
7670            Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
7671
7672        assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
7673        assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
7674        assert_eq!(dependency.mode, InstallerMode::Silent);
7675        assert!(!dependency.force);
7676        assert!(dependency.no_upgrade);
7677        assert!(dependency.accept_package_agreements);
7678        assert!(dependency.ignore_security_hash);
7679    }
7680
7681    #[test]
7682    fn storage_paths_use_configured_app_root() {
7683        let app_root = PathBuf::from(r"C:\temp\pinget-test");
7684        let source = SourceRecord {
7685            name: "winget/test".to_owned(),
7686            kind: SourceKind::PreIndexed,
7687            arg: "https://example.com/cache".to_owned(),
7688            identifier: "Test.Source".to_owned(),
7689            trust_level: "None".to_owned(),
7690            explicit: false,
7691            priority: 0,
7692            last_update: None,
7693            source_version: None,
7694        };
7695
7696        assert_eq!(store_path(&app_root), app_root.join("sources.json"));
7697        assert_eq!(
7698            source_state_dir(&app_root, &source),
7699            app_root.join("sources").join("winget_test")
7700        );
7701        assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
7702    }
7703
7704    #[cfg(windows)]
7705    #[test]
7706    fn packaged_layout_paths_resolve_from_packaged_app_root() {
7707        let local_app_data = dirs::data_local_dir().expect("LocalAppData");
7708        let app_root = local_app_data
7709            .join("Packages")
7710            .join(PACKAGED_FAMILY_NAME)
7711            .join("LocalState");
7712        assert!(uses_packaged_layout(&app_root));
7713        assert!(
7714            user_settings_path(&app_root)
7715                .display()
7716                .to_string()
7717                .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
7718        );
7719        assert!(
7720            packaged_file_cache_root(&app_root)
7721                .display()
7722                .to_string()
7723                .ends_with(&format!(
7724                    r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
7725                    PACKAGED_FAMILY_NAME
7726                ))
7727        );
7728    }
7729
7730    #[cfg(windows)]
7731    #[test]
7732    fn default_app_root_outside_package_avoids_packaged_layout() {
7733        // Tests run without AppX package identity, so the default app root must not
7734        // resolve to the WinGet packaged LocalState — that location requires a
7735        // brokered/elevated writer for its secure-settings stream.
7736        let prior = std::env::var_os("PINGET_APPROOT");
7737        // SAFETY: tests in this module are not run concurrently with other env mutators.
7738        unsafe { std::env::remove_var("PINGET_APPROOT") };
7739
7740        let result = default_app_root();
7741
7742        if let Some(prior) = prior {
7743            // SAFETY: restoring the prior value before assertions panic, single-threaded test.
7744            unsafe { std::env::set_var("PINGET_APPROOT", prior) };
7745        }
7746
7747        let app_root = result.expect("default app root");
7748        assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
7749        assert!(
7750            app_root.ends_with(Path::new("Devolutions").join("Pinget")),
7751            "unexpected fallback app root: {app_root:?}"
7752        );
7753    }
7754
7755    #[test]
7756    fn packaged_source_stream_overlays_defaults_and_metadata() {
7757        let store = parse_packaged_source_store(
7758            Some(
7759                "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",
7760            ),
7761            Some(
7762                "Sources:\n  - Name: corp\n    LastUpdate: 1700000000\n    SourceVersion: 1.2.3\n",
7763            ),
7764        )
7765        .expect("packaged store");
7766
7767        assert!(!store.sources.iter().any(|source| source.name == "winget"));
7768        let corp = store
7769            .sources
7770            .iter()
7771            .find(|source| source.name == "corp")
7772            .expect("corp source");
7773        assert_eq!(corp.kind, SourceKind::Rest);
7774        assert_eq!(corp.identifier, "Contoso.Rest");
7775        assert_eq!(corp.trust_level, "Trusted");
7776        assert!(corp.explicit);
7777        assert_eq!(corp.priority, 7);
7778        assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
7779        assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
7780    }
7781
7782    #[test]
7783    fn system_winget_export_parses_json_lines() {
7784        let output = r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
7785{"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"}
7786"#;
7787
7788        let sources = parse_system_winget_source_export(output).expect("source export");
7789
7790        assert_eq!(
7791            sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
7792            vec!["contoso", "contoso-cache"]
7793        );
7794        assert_eq!(sources[0].kind, SourceKind::Rest);
7795        assert_eq!(sources[0].arg, "https://api.contoso.test/feed");
7796        assert_eq!(sources[0].identifier, "api.contoso.test");
7797        assert_eq!(sources[0].trust_level, "Trusted");
7798        assert!(!sources[0].explicit);
7799        assert_eq!(sources[1].kind, SourceKind::PreIndexed);
7800        assert_eq!(sources[1].identifier, "Contoso.Source_8wekyb3d8bbwe");
7801        assert!(sources[1].explicit);
7802    }
7803
7804    #[test]
7805    fn system_winget_export_parses_wrapped_sources() {
7806        let output = r#"{
7807  "Sources": [
7808    {
7809      "Name": "corp",
7810      "Type": "Microsoft.Rest",
7811      "Arg": "https://packages.contoso.test/api",
7812      "Identifier": "Contoso.Rest",
7813      "TrustLevel": "Trusted",
7814      "Explicit": true,
7815      "Priority": 9
7816    }
7817  ]
7818}"#;
7819
7820        let sources = parse_system_winget_source_export(output).expect("source export");
7821        let source = sources.first().expect("source");
7822        assert_eq!(sources.len(), 1);
7823        assert_eq!(source.name, "corp");
7824        assert_eq!(source.kind, SourceKind::Rest);
7825        assert_eq!(source.identifier, "Contoso.Rest");
7826        assert_eq!(source.trust_level, "Trusted");
7827        assert!(source.explicit);
7828        assert_eq!(source.priority, 9);
7829    }
7830
7831    #[test]
7832    fn system_winget_source_store_detects_secure_settings_stub() {
7833        assert!(is_secure_settings_stub(Some(
7834            "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
7835        )));
7836        assert!(!is_secure_settings_stub(Some("Sources:\n  - Name: winget\n")));
7837    }
7838
7839    #[test]
7840    fn system_winget_source_store_builds_add_arguments() {
7841        let args = build_winget_source_add_arguments(
7842            "corp",
7843            "https://packages.contoso.test/api",
7844            SourceKind::Rest,
7845            "Trusted",
7846            true,
7847        );
7848
7849        assert_eq!(
7850            args,
7851            vec![
7852                "source",
7853                "add",
7854                "--name",
7855                "corp",
7856                "--arg",
7857                "https://packages.contoso.test/api",
7858                "--type",
7859                "Microsoft.Rest",
7860                "--disable-interactivity",
7861                "--trust-level",
7862                "trusted",
7863                "--explicit",
7864            ]
7865        );
7866    }
7867
7868    fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
7869        Ok(WingetSourceCommandResult {
7870            exit_code: 0,
7871            stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
7872"#
7873            .to_owned(),
7874            stderr: String::new(),
7875        })
7876    }
7877
7878    #[test]
7879    fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
7880        let original_runner = {
7881            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
7882            let original = *runner;
7883            *runner = fake_system_winget_source_export;
7884            original
7885        };
7886
7887        let result = load_packaged_store_from_streams(
7888            Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
7889            None,
7890        );
7891
7892        {
7893            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
7894            *runner = original_runner;
7895        }
7896
7897        let store = result.expect("packaged store").expect("packaged store");
7898        let source = store.sources.first().expect("source");
7899        assert_eq!(store.sources.len(), 1);
7900        assert_eq!(source.name, "contoso");
7901        assert_eq!(source.kind, SourceKind::Rest);
7902    }
7903
7904    #[test]
7905    fn source_metadata_and_named_reset_round_trip() {
7906        let app_root = temp_app_root("source-reset");
7907        let result = (|| -> Result<()> {
7908            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
7909            repository.add_source_with_metadata(
7910                "test",
7911                "https://example.com/test",
7912                SourceKind::Rest,
7913                Some("trusted"),
7914                true,
7915                4,
7916            )?;
7917
7918            repository.edit_source("test", Some(false), None)?;
7919            let edited = repository
7920                .list_sources()
7921                .into_iter()
7922                .find(|source| source.name == "test")
7923                .expect("source");
7924            assert_eq!(edited.trust_level, "Trusted");
7925            assert!(!edited.explicit);
7926            assert_eq!(edited.priority, 4);
7927
7928            repository.reset_source("test")?;
7929            let reset = repository
7930                .list_sources()
7931                .into_iter()
7932                .find(|source| source.name == "test")
7933                .expect("source");
7934            assert_eq!(reset.trust_level, "Trusted");
7935            assert!(!reset.explicit);
7936            assert_eq!(reset.priority, 4);
7937            assert!(reset.last_update.is_none());
7938            assert!(reset.source_version.is_none());
7939            Ok(())
7940        })();
7941
7942        let _ = fs::remove_dir_all(&app_root);
7943        result.expect("source metadata");
7944    }
7945
7946    #[test]
7947    fn explicit_sources_are_skipped_by_default_search_resolution() {
7948        let app_root = temp_app_root("source-resolution");
7949        let result = (|| -> Result<()> {
7950            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
7951            repository.add_source_with_metadata(
7952                "explicit-test",
7953                "https://example.com/explicit",
7954                SourceKind::Rest,
7955                Some("trusted"),
7956                true,
7957                10,
7958            )?;
7959
7960            let indexes = repository.resolve_source_indexes(None)?;
7961            let names: Vec<_> = indexes
7962                .into_iter()
7963                .map(|index| repository.list_sources()[index].name.clone())
7964                .collect();
7965            assert!(!names.iter().any(|name| name == "explicit-test"));
7966            Ok(())
7967        })();
7968
7969        let _ = fs::remove_dir_all(&app_root);
7970        result.expect("source resolution");
7971    }
7972
7973    #[test]
7974    fn user_and_admin_settings_round_trip() {
7975        let app_root = temp_app_root("settings");
7976        let result = (|| -> Result<()> {
7977            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
7978            repository.set_user_settings(
7979                &serde_json::json!({
7980                    "visual": {
7981                        "progressBar": "retro"
7982                    }
7983                }),
7984                false,
7985            )?;
7986            repository.set_user_settings(
7987                &serde_json::json!({
7988                    "experimentalFeatures": {
7989                        "directMSI": true
7990                    }
7991                }),
7992                true,
7993            )?;
7994
7995            let user_settings = repository.get_user_settings()?;
7996            assert_eq!(
7997                user_settings["visual"]["progressBar"],
7998                serde_json::Value::String("retro".to_owned())
7999            );
8000            assert_eq!(
8001                user_settings["experimentalFeatures"]["directMSI"],
8002                serde_json::Value::Bool(true)
8003            );
8004            assert!(repository.test_user_settings(
8005                &serde_json::json!({
8006                    "experimentalFeatures": {
8007                        "directMSI": true
8008                    }
8009                }),
8010                true
8011            )?);
8012
8013            repository.set_admin_setting("LocalManifestFiles", true)?;
8014            repository.set_admin_setting("InstallerHashOverride", true)?;
8015            let admin_settings = repository.get_admin_settings()?;
8016            assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
8017            assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
8018
8019            repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
8020            let reset_one = repository.get_admin_settings()?;
8021            assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
8022
8023            repository.reset_admin_setting(None, true)?;
8024            let reset_all = repository.get_admin_settings()?;
8025            for name in Repository::supported_admin_settings() {
8026                assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
8027            }
8028            Ok(())
8029        })();
8030
8031        let _ = fs::remove_dir_all(&app_root);
8032        result.expect("settings");
8033    }
8034
8035    #[test]
8036    fn show_result_structured_document_is_manifest_oriented() {
8037        let result = ShowResult {
8038            package: SearchMatch {
8039                source_name: "winget".to_owned(),
8040                source_kind: SourceKind::PreIndexed,
8041                id: "Test.Package".to_owned(),
8042                name: "Test Package".to_owned(),
8043                moniker: Some("testpkg".to_owned()),
8044                version: Some("1.2.3".to_owned()),
8045                channel: Some("stable".to_owned()),
8046                match_criteria: Some("Id".to_owned()),
8047            },
8048            manifest: Manifest {
8049                id: "Test.Package".to_owned(),
8050                name: "Test Package".to_owned(),
8051                version: "1.2.3".to_owned(),
8052                channel: "stable".to_owned(),
8053                publisher: Some("Contoso".to_owned()),
8054                description: Some("Structured output".to_owned()),
8055                moniker: Some("testpkg".to_owned()),
8056                package_url: None,
8057                publisher_url: None,
8058                publisher_support_url: None,
8059                license: None,
8060                license_url: None,
8061                privacy_url: None,
8062                author: None,
8063                copyright: None,
8064                copyright_url: None,
8065                release_notes: None,
8066                release_notes_url: None,
8067                tags: vec!["utility".to_owned()],
8068                agreements: Vec::new(),
8069                package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
8070                documentation: vec![Documentation {
8071                    label: Some("Docs".to_owned()),
8072                    url: "https://example.test/docs".to_owned(),
8073                }],
8074                installers: vec![Installer {
8075                    architecture: Some("x64".to_owned()),
8076                    installer_type: Some("msix".to_owned()),
8077                    url: Some("https://example.test/Test.Package.msix".to_owned()),
8078                    sha256: Some("ABC123".to_owned()),
8079                    product_code: None,
8080                    locale: Some("en-US".to_owned()),
8081                    scope: Some("machine".to_owned()),
8082                    release_date: None,
8083                    package_family_name: None,
8084                    upgrade_code: None,
8085                    platforms: Vec::new(),
8086                    minimum_os_version: None,
8087                    switches: InstallerSwitches {
8088                        silent: Some("/quiet".to_owned()),
8089                        ..InstallerSwitches::default()
8090                    },
8091                    commands: vec!["testpkg".to_owned()],
8092                    package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
8093                    require_explicit_upgrade: false,
8094                }],
8095                require_explicit_upgrade: false,
8096            },
8097            selected_installer: Some(Installer {
8098                architecture: Some("x64".to_owned()),
8099                installer_type: Some("msix".to_owned()),
8100                url: Some("https://example.test/Test.Package.msix".to_owned()),
8101                sha256: Some("ABC123".to_owned()),
8102                product_code: None,
8103                locale: Some("en-US".to_owned()),
8104                scope: Some("machine".to_owned()),
8105                release_date: None,
8106                package_family_name: None,
8107                upgrade_code: None,
8108                platforms: Vec::new(),
8109                minimum_os_version: None,
8110                switches: InstallerSwitches {
8111                    silent: Some("/quiet".to_owned()),
8112                    ..InstallerSwitches::default()
8113                },
8114                commands: vec!["testpkg".to_owned()],
8115                package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
8116                require_explicit_upgrade: false,
8117            }),
8118            cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
8119            warnings: vec!["cache warmed".to_owned()],
8120            manifest_documents: JsonValue::Array(vec![
8121                serde_json::json!({
8122                    "PackageIdentifier": "Test.Package",
8123                    "PackageVersion": "1.2.3",
8124                    "DefaultLocale": "en-US",
8125                    "ManifestType": "version",
8126                    "ManifestVersion": "1.10.0"
8127                }),
8128                serde_json::json!({
8129                    "PackageIdentifier": "Test.Package",
8130                    "PackageVersion": "1.2.3",
8131                    "PackageLocale": "en-US",
8132                    "PackageName": "Test Package",
8133                    "Publisher": "Example",
8134                    "License": "MIT",
8135                    "ShortDescription": "Structured output",
8136                    "ManifestType": "defaultLocale",
8137                    "ManifestVersion": "1.10.0"
8138                }),
8139                serde_json::json!({
8140                    "PackageIdentifier": "Test.Package",
8141                    "PackageVersion": "1.2.3",
8142                    "ManifestType": "installer",
8143                    "ManifestVersion": "1.10.0",
8144                    "Installers": [
8145                        {
8146                            "Architecture": "x64",
8147                            "InstallerType": "msix",
8148                            "InstallerUrl": "https://example.test/Test.Package.msix",
8149                            "InstallerSha256": "ABC123",
8150                            "Commands": ["testpkg"],
8151                            "InstallerSwitches": { "Silent": "/quiet" },
8152                            "Dependencies": {
8153                                "PackageDependencies": [
8154                                    { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
8155                                ]
8156                            }
8157                        }
8158                    ]
8159                }),
8160            ]),
8161        };
8162
8163        let document = result.structured_document();
8164
8165        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
8166        assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
8167        assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
8168        assert_eq!(
8169            document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
8170            Some("Microsoft.VCRedist.2015+.x64")
8171        );
8172        assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
8173        assert_eq!(
8174            document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
8175            Some("/quiet")
8176        );
8177    }
8178
8179    #[test]
8180    fn manifest_parses_require_explicit_upgrade_at_top_level() {
8181        // Top-level RequireExplicitUpgrade flag should propagate to
8182        // Manifest.require_explicit_upgrade. winget catalogs typically
8183        // place the flag here for browser packages and similar
8184        // self-updating apps that opt out of bulk `upgrade`.
8185        let yaml = r#"
8186PackageIdentifier: Test.Package
8187PackageVersion: 1.2.3
8188DefaultLocale: en-US
8189ManifestType: singleton
8190ManifestVersion: 1.10.0
8191PackageLocale: en-US
8192PackageName: Test Package
8193Publisher: Example
8194License: MIT
8195ShortDescription: explicit-upgrade fixture
8196RequireExplicitUpgrade: true
8197Installers:
8198  - Architecture: x64
8199    InstallerType: exe
8200    InstallerUrl: https://example.test/Test.Package.exe
8201    InstallerSha256: ABC123
8202"#;
8203        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
8204        assert!(manifest.require_explicit_upgrade);
8205    }
8206
8207    #[test]
8208    fn manifest_parses_require_explicit_upgrade_on_installer() {
8209        // Per-installer flag — only one of several installers declares it,
8210        // but the Manifest aggregate must still be `true` because the user
8211        // could pick that installer when upgrading.
8212        let yaml = r#"
8213PackageIdentifier: Test.Package
8214PackageVersion: 1.2.3
8215DefaultLocale: en-US
8216ManifestType: singleton
8217ManifestVersion: 1.10.0
8218PackageLocale: en-US
8219PackageName: Test Package
8220Publisher: Example
8221License: MIT
8222ShortDescription: explicit-upgrade fixture
8223Installers:
8224  - Architecture: x64
8225    InstallerType: exe
8226    InstallerUrl: https://example.test/Test.Package.x64.exe
8227    InstallerSha256: ABC123
8228  - Architecture: arm64
8229    InstallerType: exe
8230    InstallerUrl: https://example.test/Test.Package.arm64.exe
8231    InstallerSha256: DEF456
8232    RequireExplicitUpgrade: true
8233"#;
8234        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
8235        assert!(manifest.require_explicit_upgrade);
8236        assert!(!manifest.installers[0].require_explicit_upgrade);
8237        assert!(manifest.installers[1].require_explicit_upgrade);
8238    }
8239
8240    #[test]
8241    fn manifest_without_require_explicit_upgrade_defaults_to_false() {
8242        let yaml = r#"
8243PackageIdentifier: Test.Package
8244PackageVersion: 1.2.3
8245DefaultLocale: en-US
8246ManifestType: singleton
8247ManifestVersion: 1.10.0
8248PackageLocale: en-US
8249PackageName: Test Package
8250Publisher: Example
8251License: MIT
8252ShortDescription: baseline fixture
8253Installers:
8254  - Architecture: x64
8255    InstallerType: exe
8256    InstallerUrl: https://example.test/Test.Package.exe
8257    InstallerSha256: ABC123
8258"#;
8259        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
8260        assert!(!manifest.require_explicit_upgrade);
8261    }
8262
8263    #[test]
8264    fn upgrade_filter_hides_require_explicit_upgrade_by_default() {
8265        // winget hides `RequireExplicitUpgrade` rows from bulk `upgrade`
8266        // (Edge, Steam, Discord). pinget must do the same — the filter is
8267        // the only gate that enforces it.
8268        let mut pkg = InstalledPackage {
8269            name: "Edge".to_owned(),
8270            local_id: r"ARP\Machine\X64\Edge".to_owned(),
8271            installed_version: "100.0".to_owned(),
8272            publisher: None,
8273            scope: Some("Machine".to_owned()),
8274            installer_category: Some("exe".to_owned()),
8275            install_location: None,
8276            package_family_names: Vec::new(),
8277            product_codes: Vec::new(),
8278            upgrade_codes: Vec::new(),
8279            correlated: Some(SearchMatch {
8280                source_name: "winget".to_owned(),
8281                source_kind: SourceKind::PreIndexed,
8282                id: "Microsoft.Edge".to_owned(),
8283                name: "Microsoft Edge".to_owned(),
8284                moniker: None,
8285                version: Some("110.0".to_owned()),
8286                channel: None,
8287                match_criteria: None,
8288            }),
8289            installed_version_canonical: false,
8290            correlated_requires_explicit_upgrade: true,
8291            correlated_lacks_compatible_installer: false,
8292        };
8293        let bulk_query = ListQuery {
8294            upgrade_only: true,
8295            ..ListQuery::default()
8296        };
8297        assert!(
8298            !installed_package_matches_upgrade_filter(&pkg, &bulk_query),
8299            "RequireExplicitUpgrade row must be hidden from bulk upgrade"
8300        );
8301
8302        // When the user explicitly targets it by id, winget shows it —
8303        // pinget must do the same so `pinget upgrade Microsoft.Edge` works.
8304        let filtered_query = ListQuery {
8305            upgrade_only: true,
8306            id: Some("Microsoft.Edge".to_owned()),
8307            ..ListQuery::default()
8308        };
8309        assert!(
8310            installed_package_matches_upgrade_filter(&pkg, &filtered_query),
8311            "RequireExplicitUpgrade row must surface when the user filters for it explicitly"
8312        );
8313
8314        // Without the flag, the row appears in bulk upgrade as usual.
8315        pkg.correlated_requires_explicit_upgrade = false;
8316        assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
8317    }
8318
8319    #[test]
8320    fn upgrade_filter_hides_lacks_compatible_installer_by_default() {
8321        let mut pkg = InstalledPackage {
8322            name: "Foo".to_owned(),
8323            local_id: r"ARP\Machine\X64\Foo".to_owned(),
8324            installed_version: "1.0".to_owned(),
8325            publisher: None,
8326            scope: Some("Machine".to_owned()),
8327            installer_category: Some("exe".to_owned()),
8328            install_location: None,
8329            package_family_names: Vec::new(),
8330            product_codes: Vec::new(),
8331            upgrade_codes: Vec::new(),
8332            correlated: Some(SearchMatch {
8333                source_name: "winget".to_owned(),
8334                source_kind: SourceKind::PreIndexed,
8335                id: "Test.Foo".to_owned(),
8336                name: "Foo".to_owned(),
8337                moniker: None,
8338                version: Some("2.0".to_owned()),
8339                channel: None,
8340                match_criteria: None,
8341            }),
8342            installed_version_canonical: false,
8343            correlated_requires_explicit_upgrade: false,
8344            correlated_lacks_compatible_installer: true,
8345        };
8346        let bulk_query = ListQuery {
8347            upgrade_only: true,
8348            ..ListQuery::default()
8349        };
8350        assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query));
8351
8352        // Explicit `--id` still surfaces it.
8353        let filtered_query = ListQuery {
8354            upgrade_only: true,
8355            id: Some("Test.Foo".to_owned()),
8356            ..ListQuery::default()
8357        };
8358        assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query));
8359
8360        // Cleared flag → visible in bulk.
8361        pkg.correlated_lacks_compatible_installer = false;
8362        assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
8363    }
8364
8365    fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest {
8366        let installers = arches
8367            .iter()
8368            .map(|a| Installer {
8369                architecture: Some((*a).to_owned()),
8370                installer_type: None,
8371                url: None,
8372                sha256: None,
8373                product_code: None,
8374                locale: None,
8375                scope: None,
8376                release_date: None,
8377                package_family_name: None,
8378                upgrade_code: None,
8379                platforms: Vec::new(),
8380                minimum_os_version: None,
8381                switches: InstallerSwitches::default(),
8382                commands: Vec::new(),
8383                package_dependencies: Vec::new(),
8384                require_explicit_upgrade: false,
8385            })
8386            .collect();
8387        Manifest {
8388            id: "Test".to_owned(),
8389            name: "Test".to_owned(),
8390            version: "1.0".to_owned(),
8391            channel: String::new(),
8392            publisher: None,
8393            description: None,
8394            moniker: None,
8395            package_url: None,
8396            publisher_url: None,
8397            publisher_support_url: None,
8398            license: None,
8399            license_url: None,
8400            privacy_url: None,
8401            author: None,
8402            copyright: None,
8403            copyright_url: None,
8404            release_notes: None,
8405            release_notes_url: None,
8406            tags: Vec::new(),
8407            agreements: Vec::new(),
8408            package_dependencies: Vec::new(),
8409            documentation: Vec::new(),
8410            installers,
8411            require_explicit_upgrade: false,
8412        }
8413    }
8414
8415    #[test]
8416    fn manifest_compatible_installer_neutral_always_matches() {
8417        // `neutral` is in every host's allowed list.
8418        assert!(manifest_has_compatible_installer(
8419            &synthetic_manifest_with_installer_arches(&["neutral"])
8420        ));
8421    }
8422
8423    #[test]
8424    fn manifest_compatible_installer_empty_installers_pass_through() {
8425        // Manifest with no installers (e.g. a docs-only entry): nothing to
8426        // filter against, don't hide the row.
8427        assert!(manifest_has_compatible_installer(
8428            &synthetic_manifest_with_installer_arches(&[])
8429        ));
8430    }
8431
8432    #[test]
8433    fn manifest_compatible_installer_rejects_alien_arch_only() {
8434        // `ppc` is in no host's allowed list — manifest has nothing runnable.
8435        assert!(!manifest_has_compatible_installer(
8436            &synthetic_manifest_with_installer_arches(&["ppc"])
8437        ));
8438    }
8439
8440    #[test]
8441    fn manifest_compatible_installer_mixed_set_passes_if_any_match() {
8442        // Mixed list where at least `neutral` is in every host's preference.
8443        assert!(manifest_has_compatible_installer(
8444            &synthetic_manifest_with_installer_arches(&["ppc", "neutral"])
8445        ));
8446    }
8447
8448    #[test]
8449    fn lookup_unique_normalized_identity_returns_unique_match() {
8450        // Single (norm_name, norm_publisher) intersection — happy path.
8451        let connection = Connection::open_in_memory().expect("open in-memory db");
8452        connection
8453            .execute_batch(
8454                "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
8455                 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
8456                 INSERT INTO norm_names2 VALUES ('microsoftedge', 100);\n\
8457                 INSERT INTO norm_publishers2 VALUES ('microsoft', 100);",
8458            )
8459            .expect("seed schema");
8460
8461        let rowid = lookup_unique_normalized_identity(&connection, "microsoftedge", "microsoft")
8462            .expect("query")
8463            .expect("unique match");
8464        assert_eq!(rowid, 100);
8465    }
8466
8467    #[test]
8468    fn lookup_unique_normalized_identity_rejects_ambiguous_match() {
8469        // Two distinct packages share the same (norm_name, norm_publisher)
8470        // — the Git case where both `Git.Git` and a hypothetical sibling
8471        // normalize identically. winget refuses to correlate when it can't
8472        // disambiguate; pinget must do the same.
8473        let connection = Connection::open_in_memory().expect("open in-memory db");
8474        connection
8475            .execute_batch(
8476                "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
8477                 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
8478                 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
8479                 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('thegitdevelopmentcommunity', 200);",
8480            )
8481            .expect("seed schema");
8482
8483        let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
8484        assert!(rowid.is_none(), "ambiguous match must not correlate");
8485    }
8486
8487    #[test]
8488    fn lookup_unique_normalized_identity_requires_publisher_intersect() {
8489        // norm_name has multiple matches but only one shares its
8490        // norm_publisher with the installed package. Winget's intersect
8491        // logic still picks the right one — verify.
8492        let connection = Connection::open_in_memory().expect("open in-memory db");
8493        connection
8494            .execute_batch(
8495                "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
8496                 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
8497                 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
8498                 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('microsoft', 200);",
8499            )
8500            .expect("seed schema");
8501
8502        let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
8503        assert_eq!(rowid, Some(100));
8504    }
8505
8506    #[test]
8507    fn lookup_unique_normalized_identity_misses_when_publisher_does_not_match() {
8508        // Name matches but no publisher row for that package id —
8509        // intersection is empty so we don't correlate.
8510        let connection = Connection::open_in_memory().expect("open in-memory db");
8511        connection
8512            .execute_batch(
8513                "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
8514                 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
8515                 INSERT INTO norm_names2 VALUES ('foo', 100);\n\
8516                 INSERT INTO norm_publishers2 VALUES ('bar', 200);",
8517            )
8518            .expect("seed schema");
8519
8520        let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query");
8521        assert!(
8522            rowid.is_none(),
8523            "name matches package 100 but publisher matches 200 — no intersection"
8524        );
8525    }
8526
8527    #[test]
8528    fn parse_yaml_manifest_bundle_returns_singleton_document() {
8529        let yaml = r#"
8530PackageIdentifier: Test.Package
8531PackageVersion: 1.2.3
8532DefaultLocale: en-US
8533ManifestType: version
8534ManifestVersion: 1.10.0
8535---
8536PackageIdentifier: Test.Package
8537PackageVersion: 1.2.3
8538PackageLocale: en-US
8539PackageName: Test Package
8540Publisher: Example
8541License: MIT
8542ShortDescription: Structured output
8543ManifestType: defaultLocale
8544ManifestVersion: 1.10.0
8545---
8546PackageIdentifier: Test.Package
8547PackageVersion: 1.2.3
8548ManifestType: installer
8549ManifestVersion: 1.10.0
8550Installers:
8551  - Architecture: x64
8552    InstallerType: exe
8553    InstallerUrl: https://example.test/Test.Package.exe
8554    InstallerSha256: ABC123
8555"#;
8556
8557        let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
8558
8559        assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
8560        assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
8561        assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
8562    }
8563
8564    #[test]
8565    fn collapse_structured_documents_returns_plural_show_documents() {
8566        let documents = collapse_structured_documents(&[
8567            JsonValue::Array(vec![
8568                serde_json::json!({
8569                    "PackageIdentifier": "Test.Package.One",
8570                    "PackageVersion": "1.0.0",
8571                    "DefaultLocale": "en-US",
8572                    "ManifestType": "version",
8573                    "ManifestVersion": "1.10.0"
8574                }),
8575                serde_json::json!({
8576                    "PackageIdentifier": "Test.Package.One",
8577                    "PackageVersion": "1.0.0",
8578                    "PackageLocale": "en-US",
8579                    "PackageName": "Test Package One",
8580                    "ManifestType": "defaultLocale",
8581                    "ManifestVersion": "1.10.0"
8582                }),
8583                serde_json::json!({
8584                    "PackageIdentifier": "Test.Package.One",
8585                    "PackageVersion": "1.0.0",
8586                    "ManifestType": "installer",
8587                    "ManifestVersion": "1.10.0",
8588                    "Installers": [
8589                        {
8590                            "Architecture": "x64",
8591                            "InstallerType": "exe",
8592                            "InstallerUrl": "https://example.test/one.exe",
8593                            "InstallerSha256": "ABC123"
8594                        }
8595                    ]
8596                }),
8597            ]),
8598            serde_json::json!({
8599                "PackageIdentifier": "Test.Package.Two",
8600                "PackageVersion": "2.0.0",
8601                "PackageLocale": "en-US",
8602                "PackageName": "Test Package Two",
8603                "ManifestType": "singleton",
8604                "ManifestVersion": "1.12.0"
8605            }),
8606        ]);
8607
8608        assert_eq!(documents.len(), 2);
8609        assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
8610        assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
8611        assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
8612        assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
8613        assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
8614    }
8615
8616    #[test]
8617    fn unsupported_action_result_marks_noop_and_warning() {
8618        let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
8619
8620        assert!(result.success);
8621        assert!(result.no_op);
8622        assert_eq!(result.exit_code, 0);
8623        assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
8624    }
8625
8626    #[test]
8627    fn create_install_no_op_result_honors_no_upgrade() {
8628        let mut request = InstallRequest::new(PackageQuery {
8629            id: Some("Contoso.App".to_owned()),
8630            ..PackageQuery::default()
8631        });
8632        request.no_upgrade = true;
8633
8634        let manifest = Manifest {
8635            id: "Contoso.App".to_owned(),
8636            name: "Contoso App".to_owned(),
8637            version: "2.0.0".to_owned(),
8638            channel: String::new(),
8639            publisher: None,
8640            description: None,
8641            moniker: None,
8642            package_url: None,
8643            publisher_url: None,
8644            publisher_support_url: None,
8645            license: None,
8646            license_url: None,
8647            privacy_url: None,
8648            author: None,
8649            copyright: None,
8650            copyright_url: None,
8651            release_notes: None,
8652            release_notes_url: None,
8653            tags: Vec::new(),
8654            agreements: Vec::new(),
8655            package_dependencies: Vec::new(),
8656            documentation: Vec::new(),
8657            installers: Vec::new(),
8658            require_explicit_upgrade: false,
8659        };
8660        let existing = ListMatch {
8661            name: "Contoso App".to_owned(),
8662            id: "Contoso.App".to_owned(),
8663            local_id: "Contoso.App".to_owned(),
8664            installed_version: "1.0.0".to_owned(),
8665            available_version: Some("2.0.0".to_owned()),
8666            source_name: Some("winget".to_owned()),
8667            publisher: None,
8668            scope: None,
8669            installer_category: None,
8670            install_location: None,
8671            package_family_names: Vec::new(),
8672            product_codes: Vec::new(),
8673            upgrade_codes: Vec::new(),
8674        };
8675
8676        let result =
8677            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
8678
8679        assert!(result.success);
8680        assert!(result.no_op);
8681        assert_eq!(result.version, "1.0.0");
8682        assert_eq!(
8683            result.warnings,
8684            vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
8685        );
8686    }
8687
8688    #[test]
8689    fn create_install_no_op_result_skips_reinstall_when_current() {
8690        let request = InstallRequest::new(PackageQuery {
8691            id: Some("Contoso.App".to_owned()),
8692            ..PackageQuery::default()
8693        });
8694
8695        let manifest = Manifest {
8696            id: "Contoso.App".to_owned(),
8697            name: "Contoso App".to_owned(),
8698            version: "2.0.0".to_owned(),
8699            channel: String::new(),
8700            publisher: None,
8701            description: None,
8702            moniker: None,
8703            package_url: None,
8704            publisher_url: None,
8705            publisher_support_url: None,
8706            license: None,
8707            license_url: None,
8708            privacy_url: None,
8709            author: None,
8710            copyright: None,
8711            copyright_url: None,
8712            release_notes: None,
8713            release_notes_url: None,
8714            tags: Vec::new(),
8715            agreements: Vec::new(),
8716            package_dependencies: Vec::new(),
8717            documentation: Vec::new(),
8718            installers: Vec::new(),
8719            require_explicit_upgrade: false,
8720        };
8721        let existing = ListMatch {
8722            name: "Contoso App".to_owned(),
8723            id: "Contoso.App".to_owned(),
8724            local_id: "Contoso.App".to_owned(),
8725            installed_version: "2.0.0".to_owned(),
8726            available_version: Some("2.0.0".to_owned()),
8727            source_name: Some("winget".to_owned()),
8728            publisher: None,
8729            scope: None,
8730            installer_category: None,
8731            install_location: None,
8732            package_family_names: Vec::new(),
8733            product_codes: Vec::new(),
8734            upgrade_codes: Vec::new(),
8735        };
8736
8737        let result =
8738            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
8739
8740        assert!(result.success);
8741        assert!(result.no_op);
8742        assert_eq!(result.version, "2.0.0");
8743        assert_eq!(
8744            result.warnings,
8745            vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
8746        );
8747    }
8748
8749    #[test]
8750    fn collapse_structured_document_projects_merged_manifest() {
8751        let document = collapse_structured_document(&serde_json::json!({
8752            "PackageIdentifier": "Test.Package",
8753            "PackageVersion": "1.2.3",
8754            "PackageLocale": "en-US",
8755            "PackageName": "Test Package",
8756            "Publisher": "Example",
8757            "InstallerType": "exe",
8758            "Installers": [
8759                {
8760                    "Architecture": "x64",
8761                    "InstallerUrl": "https://example.test/Test.Package.exe",
8762                    "InstallerSha256": "ABC123"
8763                }
8764            ],
8765            "ManifestType": "merged",
8766            "ManifestVersion": "1.10.0"
8767        }));
8768
8769        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
8770        assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
8771        assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
8772        assert_eq!(document["InstallerType"].as_str(), Some("exe"));
8773        assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
8774    }
8775
8776    #[test]
8777    fn parses_fixture_manifest() {
8778        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
8779            .join("tests")
8780            .join("fixtures")
8781            .join("ManifestV1_28-Singleton.yaml");
8782        let bytes = fs::read(root).expect("fixture bytes");
8783        let manifest = parse_yaml_manifest(&bytes).expect("manifest");
8784        assert_eq!(manifest.id, "microsoft.msixsdk");
8785        assert_eq!(manifest.version, "1.7.32");
8786        assert_eq!(manifest.name, "MSIX SDK");
8787        assert!(!manifest.installers.is_empty());
8788    }
8789
8790    #[test]
8791    fn parses_installer_switches_from_yaml_manifest() {
8792        let yaml = r#"
8793PackageIdentifier: Test.Package
8794PackageVersion: 1.2.3
8795PackageName: Test Package
8796InstallerSwitches:
8797  SilentWithProgress: /SILENT
8798Installers:
8799  - Architecture: x64
8800    InstallerType: inno
8801    InstallerUrl: https://example.test/Test.Package.exe
8802    InstallerSha256: ABC123
8803    InstallerSwitches:
8804      Silent: /VERYSILENT
8805      Interactive: /HELP
8806"#;
8807
8808        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
8809        let installer = &manifest.installers[0];
8810
8811        assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
8812        assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
8813        assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
8814    }
8815
8816    #[test]
8817    fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
8818        let yaml = r#"
8819PackageIdentifier: Test.Package
8820PackageVersion: 1.2.3
8821PackageName: Test Package
8822Platform:
8823  - Windows.Desktop
8824MinimumOSVersion: 10.0.19041.0
8825Installers:
8826  - Architecture: x64
8827    InstallerType: exe
8828    InstallerUrl: https://example.test/Test.Package.exe
8829    InstallerSha256: ABC123
8830  - Architecture: x64
8831    InstallerType: msix
8832    Platform:
8833      - Windows.Universal
8834    MinimumOSVersion: 10.0.22621.0
8835    InstallerUrl: https://example.test/Test.Package.msix
8836    InstallerSha256: DEF456
8837"#;
8838
8839        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
8840
8841        assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
8842        assert_eq!(
8843            manifest.installers[0].minimum_os_version.as_deref(),
8844            Some("10.0.19041.0")
8845        );
8846        assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
8847        assert_eq!(
8848            manifest.installers[1].minimum_os_version.as_deref(),
8849            Some("10.0.22621.0")
8850        );
8851    }
8852
8853    #[test]
8854    fn installer_switch_arguments_prefer_manifest_switches() {
8855        let installer = Installer {
8856            architecture: None,
8857            installer_type: Some("inno".to_owned()),
8858            url: None,
8859            sha256: None,
8860            product_code: None,
8861            locale: None,
8862            scope: None,
8863            release_date: None,
8864            package_family_name: None,
8865            upgrade_code: None,
8866            platforms: Vec::new(),
8867            minimum_os_version: None,
8868            switches: InstallerSwitches {
8869                silent: Some("/mysilent".to_owned()),
8870                silent_with_progress: Some("/mysilentwithprogress".to_owned()),
8871                interactive: Some("/myinteractive".to_owned()),
8872                ..InstallerSwitches::default()
8873            },
8874            commands: Vec::new(),
8875            package_dependencies: Vec::new(),
8876            require_explicit_upgrade: false,
8877        };
8878        let manifest = Manifest {
8879            id: "Test.Package".to_owned(),
8880            name: "Test Package".to_owned(),
8881            version: "1.0.0".to_owned(),
8882            channel: String::new(),
8883            publisher: None,
8884            description: None,
8885            moniker: None,
8886            package_url: None,
8887            publisher_url: None,
8888            publisher_support_url: None,
8889            license: None,
8890            license_url: None,
8891            privacy_url: None,
8892            author: None,
8893            copyright: None,
8894            copyright_url: None,
8895            release_notes: None,
8896            release_notes_url: None,
8897            tags: Vec::new(),
8898            agreements: Vec::new(),
8899            package_dependencies: Vec::new(),
8900            documentation: Vec::new(),
8901            installers: Vec::new(),
8902            require_explicit_upgrade: false,
8903        };
8904
8905        let mut silent_request = InstallRequest::new(PackageQuery::default());
8906        silent_request.mode = InstallerMode::Silent;
8907        let mut progress_request = InstallRequest::new(PackageQuery::default());
8908        progress_request.mode = InstallerMode::SilentWithProgress;
8909        let mut interactive_request = InstallRequest::new(PackageQuery::default());
8910        interactive_request.mode = InstallerMode::Interactive;
8911        assert_eq!(
8912            installer_command_arguments(
8913                "inno",
8914                &silent_request,
8915                &manifest,
8916                Path::new("installer.exe"),
8917                &installer
8918            ),
8919            vec!["/mysilent".to_owned()]
8920        );
8921        assert_eq!(
8922            installer_command_arguments(
8923                "inno",
8924                &progress_request,
8925                &manifest,
8926                Path::new("installer.exe"),
8927                &installer
8928            ),
8929            vec!["/mysilentwithprogress".to_owned()]
8930        );
8931        assert_eq!(
8932            installer_command_arguments(
8933                "inno",
8934                &interactive_request,
8935                &manifest,
8936                Path::new("installer.exe"),
8937                &installer
8938            ),
8939            vec!["/myinteractive".to_owned()]
8940        );
8941    }
8942
8943    #[test]
8944    fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
8945        let installer = Installer {
8946            architecture: None,
8947            installer_type: Some("inno".to_owned()),
8948            url: None,
8949            sha256: None,
8950            product_code: None,
8951            locale: None,
8952            scope: None,
8953            release_date: None,
8954            package_family_name: None,
8955            upgrade_code: None,
8956            platforms: Vec::new(),
8957            minimum_os_version: None,
8958            switches: InstallerSwitches::default(),
8959            commands: Vec::new(),
8960            package_dependencies: Vec::new(),
8961            require_explicit_upgrade: false,
8962        };
8963        let manifest = Manifest {
8964            id: "Test.Package".to_owned(),
8965            name: "Test Package".to_owned(),
8966            version: "1.0.0".to_owned(),
8967            channel: String::new(),
8968            publisher: None,
8969            description: None,
8970            moniker: None,
8971            package_url: None,
8972            publisher_url: None,
8973            publisher_support_url: None,
8974            license: None,
8975            license_url: None,
8976            privacy_url: None,
8977            author: None,
8978            copyright: None,
8979            copyright_url: None,
8980            release_notes: None,
8981            release_notes_url: None,
8982            tags: Vec::new(),
8983            agreements: Vec::new(),
8984            package_dependencies: Vec::new(),
8985            documentation: Vec::new(),
8986            installers: Vec::new(),
8987            require_explicit_upgrade: false,
8988        };
8989        let mut progress_request = InstallRequest::new(PackageQuery::default());
8990        progress_request.mode = InstallerMode::SilentWithProgress;
8991        let mut silent_request = InstallRequest::new(PackageQuery::default());
8992        silent_request.mode = InstallerMode::Silent;
8993
8994        assert_eq!(
8995            installer_command_arguments(
8996                "inno",
8997                &progress_request,
8998                &manifest,
8999                Path::new("installer.exe"),
9000                &installer
9001            ),
9002            vec![
9003                "/SP-".to_owned(),
9004                "/SILENT".to_owned(),
9005                "/SUPPRESSMSGBOXES".to_owned(),
9006                "/NORESTART".to_owned()
9007            ]
9008        );
9009        assert_eq!(
9010            installer_command_arguments(
9011                "inno",
9012                &silent_request,
9013                &manifest,
9014                Path::new("installer.exe"),
9015                &installer
9016            ),
9017            vec![
9018                "/SP-".to_owned(),
9019                "/VERYSILENT".to_owned(),
9020                "/SUPPRESSMSGBOXES".to_owned(),
9021                "/NORESTART".to_owned()
9022            ]
9023        );
9024    }
9025
9026    #[test]
9027    fn installer_command_arguments_append_manifest_and_cli_switches() {
9028        let installer = Installer {
9029            architecture: None,
9030            installer_type: Some("msi".to_owned()),
9031            url: None,
9032            sha256: None,
9033            product_code: None,
9034            locale: None,
9035            scope: None,
9036            release_date: None,
9037            package_family_name: None,
9038            upgrade_code: None,
9039            platforms: Vec::new(),
9040            minimum_os_version: None,
9041            switches: InstallerSwitches {
9042                custom: Some("ADDLOCAL=Core".to_owned()),
9043                log: Some("/log \"<LOGPATH>\"".to_owned()),
9044                install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
9045                ..InstallerSwitches::default()
9046            },
9047            commands: Vec::new(),
9048            package_dependencies: Vec::new(),
9049            require_explicit_upgrade: false,
9050        };
9051        let manifest = Manifest {
9052            id: "ShareX.ShareX".to_owned(),
9053            name: "ShareX".to_owned(),
9054            version: "19.0.2".to_owned(),
9055            channel: String::new(),
9056            publisher: None,
9057            description: None,
9058            moniker: None,
9059            package_url: None,
9060            publisher_url: None,
9061            publisher_support_url: None,
9062            license: None,
9063            license_url: None,
9064            privacy_url: None,
9065            author: None,
9066            copyright: None,
9067            copyright_url: None,
9068            release_notes: None,
9069            release_notes_url: None,
9070            tags: Vec::new(),
9071            agreements: Vec::new(),
9072            package_dependencies: Vec::new(),
9073            documentation: Vec::new(),
9074            installers: Vec::new(),
9075            require_explicit_upgrade: false,
9076        };
9077        let mut request = InstallRequest::new(PackageQuery::default());
9078        request.mode = InstallerMode::Silent;
9079        request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
9080        request.custom = Some("REBOOT=ReallySuppress".to_owned());
9081        request.install_location = Some(r"C:\Apps\ShareX".to_owned());
9082
9083        assert_eq!(
9084            installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
9085            vec![
9086                "/i".to_owned(),
9087                r"C:\temp\ShareX.msi".to_owned(),
9088                "/quiet".to_owned(),
9089                "/norestart".to_owned(),
9090                "/log".to_owned(),
9091                r"C:\temp\winget.log".to_owned(),
9092                "ADDLOCAL=Core".to_owned(),
9093                "REBOOT=ReallySuppress".to_owned(),
9094                r"TARGETDIR=C:\Apps\ShareX".to_owned(),
9095            ]
9096        );
9097    }
9098
9099    #[test]
9100    fn decompresses_mszyml_payload() {
9101        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
9102        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
9103        encoder.write_all(payload.as_bytes()).expect("write payload");
9104        let compressed = encoder.finish().expect("finish payload");
9105        let mut mszip = b"CK".to_vec();
9106        mszip.extend_from_slice(&compressed);
9107
9108        let decompressed = decompress_mszyml(&mszip).expect("decompress");
9109        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
9110        assert_eq!(parsed.versions[0].version, "1.2.3");
9111    }
9112
9113    #[test]
9114    fn decompresses_mszyml_payload_with_prefix() {
9115        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
9116        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
9117        encoder.write_all(payload.as_bytes()).expect("write payload");
9118        let compressed = encoder.finish().expect("finish payload");
9119        let mut prefixed = vec![0u8; 28];
9120        prefixed.extend_from_slice(b"CK");
9121        prefixed.extend_from_slice(&compressed);
9122
9123        let decompressed = decompress_mszyml(&prefixed).expect("decompress");
9124        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
9125        assert_eq!(parsed.versions[0].version, "1.2.3");
9126    }
9127
9128    #[test]
9129    fn compares_versions_naturally() {
9130        assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
9131        assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
9132        assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
9133    }
9134
9135    #[test]
9136    fn installer_hash_verification_fails_on_mismatch_without_override() {
9137        let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
9138        assert!(result.is_err());
9139        assert!(
9140            result
9141                .expect_err("hash mismatch")
9142                .to_string()
9143                .contains("Installer hash mismatch")
9144        );
9145    }
9146
9147    #[test]
9148    fn installer_hash_verification_allows_mismatch_with_override() {
9149        verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
9150        verify_installer_hash(None, b"pinget", false).expect("missing hash");
9151    }
9152
9153    #[test]
9154    fn search_query_many_includes_tag_and_command_conditions() {
9155        let query = PackageQuery {
9156            query: Some("terminal".to_owned()),
9157            ..PackageQuery::default()
9158        };
9159
9160        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
9161
9162        assert!(where_clause.contains("tags2"));
9163        assert!(where_clause.contains("commands2"));
9164        assert_eq!(params.len(), 5);
9165    }
9166
9167    #[test]
9168    fn show_query_single_omits_tag_and_command_conditions() {
9169        let query = PackageQuery {
9170            query: Some("terminal".to_owned()),
9171            ..PackageQuery::default()
9172        };
9173
9174        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
9175
9176        assert!(!where_clause.contains("tags2"));
9177        assert!(!where_clause.contains("commands2"));
9178        assert_eq!(params.len(), 3);
9179    }
9180
9181    #[test]
9182    fn explicit_tag_filter_uses_exact_match() {
9183        let query = PackageQuery {
9184            tag: Some("terminal".to_owned()),
9185            ..PackageQuery::default()
9186        };
9187
9188        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
9189
9190        assert!(where_clause.contains("tags2"));
9191        assert_eq!(params, vec!["terminal".to_owned()]);
9192    }
9193
9194    #[test]
9195    fn rest_tag_filter_uses_exact_match() {
9196        let query = PackageQuery {
9197            tag: Some("terminal".to_owned()),
9198            ..PackageQuery::default()
9199        };
9200        let info = RestInformation {
9201            required_package_match_fields: Vec::new(),
9202            unsupported_package_match_fields: Vec::new(),
9203            required_query_parameters: Vec::new(),
9204            ..RestInformation::default()
9205        };
9206
9207        let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
9208        let filters = body["Filters"].as_array().expect("filters");
9209
9210        assert_eq!(filters.len(), 1);
9211        assert_eq!(filters[0]["PackageMatchField"], "Tag");
9212        assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
9213        assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
9214    }
9215
9216    #[test]
9217    fn selects_installer_using_requested_filters() {
9218        let installers = vec![
9219            Installer {
9220                architecture: Some("x86".to_owned()),
9221                installer_type: Some("zip".to_owned()),
9222                url: None,
9223                sha256: None,
9224                product_code: None,
9225                locale: Some("en-US".to_owned()),
9226                scope: Some("user".to_owned()),
9227                release_date: None,
9228                package_family_name: None,
9229                upgrade_code: None,
9230                platforms: Vec::new(),
9231                minimum_os_version: None,
9232                switches: InstallerSwitches::default(),
9233                commands: Vec::new(),
9234                package_dependencies: Vec::new(),
9235                require_explicit_upgrade: false,
9236            },
9237            Installer {
9238                architecture: Some("x64".to_owned()),
9239                installer_type: Some("msix".to_owned()),
9240                url: None,
9241                sha256: None,
9242                product_code: None,
9243                locale: Some("en-US".to_owned()),
9244                scope: Some("user".to_owned()),
9245                release_date: None,
9246                package_family_name: None,
9247                upgrade_code: None,
9248                platforms: Vec::new(),
9249                minimum_os_version: None,
9250                switches: InstallerSwitches::default(),
9251                commands: vec!["demo".to_owned()],
9252                package_dependencies: Vec::new(),
9253                require_explicit_upgrade: false,
9254            },
9255        ];
9256        let query = PackageQuery {
9257            installer_type: Some("msix".to_owned()),
9258            installer_architecture: Some("x64".to_owned()),
9259            install_scope: Some("user".to_owned()),
9260            locale: Some("en-US".to_owned()),
9261            ..PackageQuery::default()
9262        };
9263
9264        let selected = select_installer(&installers, &query).expect("selected installer");
9265        assert_eq!(selected.installer_type.as_deref(), Some("msix"));
9266        assert_eq!(selected.architecture.as_deref(), Some("x64"));
9267    }
9268
9269    #[test]
9270    fn selects_first_installer_when_rank_ties() {
9271        let installers = vec![
9272            Installer {
9273                architecture: Some("x64".to_owned()),
9274                installer_type: Some("exe".to_owned()),
9275                url: None,
9276                sha256: None,
9277                product_code: None,
9278                locale: Some("en-US".to_owned()),
9279                scope: Some("machine".to_owned()),
9280                release_date: None,
9281                package_family_name: None,
9282                upgrade_code: None,
9283                platforms: Vec::new(),
9284                minimum_os_version: None,
9285                switches: InstallerSwitches::default(),
9286                commands: Vec::new(),
9287                package_dependencies: Vec::new(),
9288                require_explicit_upgrade: false,
9289            },
9290            Installer {
9291                architecture: Some("x64".to_owned()),
9292                installer_type: Some("exe".to_owned()),
9293                url: None,
9294                sha256: None,
9295                product_code: None,
9296                locale: Some("en-US".to_owned()),
9297                scope: Some("user".to_owned()),
9298                release_date: None,
9299                package_family_name: None,
9300                upgrade_code: None,
9301                platforms: Vec::new(),
9302                minimum_os_version: None,
9303                switches: InstallerSwitches::default(),
9304                commands: Vec::new(),
9305                package_dependencies: Vec::new(),
9306                require_explicit_upgrade: false,
9307            },
9308        ];
9309
9310        let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
9311        assert_eq!(selected.scope.as_deref(), Some("machine"));
9312    }
9313
9314    #[test]
9315    fn selects_installer_using_requested_platform() {
9316        let installers = vec![
9317            Installer {
9318                architecture: Some("x64".to_owned()),
9319                installer_type: Some("exe".to_owned()),
9320                url: None,
9321                sha256: None,
9322                product_code: None,
9323                locale: Some("en-US".to_owned()),
9324                scope: Some("user".to_owned()),
9325                release_date: None,
9326                package_family_name: None,
9327                upgrade_code: None,
9328                platforms: vec!["Windows.Universal".to_owned()],
9329                minimum_os_version: None,
9330                switches: InstallerSwitches::default(),
9331                commands: Vec::new(),
9332                package_dependencies: Vec::new(),
9333                require_explicit_upgrade: false,
9334            },
9335            Installer {
9336                architecture: Some("x64".to_owned()),
9337                installer_type: Some("exe".to_owned()),
9338                url: None,
9339                sha256: None,
9340                product_code: None,
9341                locale: Some("en-US".to_owned()),
9342                scope: Some("user".to_owned()),
9343                release_date: None,
9344                package_family_name: None,
9345                upgrade_code: None,
9346                platforms: vec!["Windows.Desktop".to_owned()],
9347                minimum_os_version: None,
9348                switches: InstallerSwitches::default(),
9349                commands: Vec::new(),
9350                package_dependencies: Vec::new(),
9351                require_explicit_upgrade: false,
9352            },
9353        ];
9354        let query = PackageQuery {
9355            installer_type: Some("exe".to_owned()),
9356            platform: Some("Windows.Desktop".to_owned()),
9357            ..PackageQuery::default()
9358        };
9359
9360        let selected = select_installer(&installers, &query).expect("selected installer");
9361        assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
9362    }
9363
9364    #[test]
9365    fn selects_installer_using_requested_os_version() {
9366        let installers = vec![
9367            Installer {
9368                architecture: Some("x64".to_owned()),
9369                installer_type: Some("exe".to_owned()),
9370                url: None,
9371                sha256: None,
9372                product_code: None,
9373                locale: Some("en-US".to_owned()),
9374                scope: Some("user".to_owned()),
9375                release_date: None,
9376                package_family_name: None,
9377                upgrade_code: None,
9378                platforms: Vec::new(),
9379                minimum_os_version: Some("10.0.22621.0".to_owned()),
9380                switches: InstallerSwitches::default(),
9381                commands: Vec::new(),
9382                package_dependencies: Vec::new(),
9383                require_explicit_upgrade: false,
9384            },
9385            Installer {
9386                architecture: Some("x64".to_owned()),
9387                installer_type: Some("exe".to_owned()),
9388                url: None,
9389                sha256: None,
9390                product_code: None,
9391                locale: Some("en-US".to_owned()),
9392                scope: Some("user".to_owned()),
9393                release_date: None,
9394                package_family_name: None,
9395                upgrade_code: None,
9396                platforms: Vec::new(),
9397                minimum_os_version: Some("10.0.19041.0".to_owned()),
9398                switches: InstallerSwitches::default(),
9399                commands: Vec::new(),
9400                package_dependencies: Vec::new(),
9401                require_explicit_upgrade: false,
9402            },
9403        ];
9404        let query = PackageQuery {
9405            installer_type: Some("exe".to_owned()),
9406            os_version: Some("10.0.19045.0".to_owned()),
9407            ..PackageQuery::default()
9408        };
9409
9410        let selected = select_installer(&installers, &query).expect("selected installer");
9411        assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
9412    }
9413
9414    #[test]
9415    fn derives_correlation_name_candidates() {
9416        let candidates = correlation_name_candidates("PowerToys (Preview) x64");
9417        assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
9418        assert!(candidates.contains(&"PowerToys".to_owned()));
9419    }
9420
9421    #[test]
9422    fn correlates_installed_package_to_available_match() {
9423        let installed = InstalledPackage {
9424            name: "PowerToys (Preview) x64".to_owned(),
9425            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
9426            installed_version: "0.98.1".to_owned(),
9427            publisher: None,
9428            scope: Some("Machine".to_owned()),
9429            installer_category: Some("exe".to_owned()),
9430            install_location: None,
9431            package_family_names: Vec::new(),
9432            product_codes: Vec::new(),
9433            upgrade_codes: Vec::new(),
9434            correlated: None,
9435            installed_version_canonical: false,
9436            correlated_requires_explicit_upgrade: false,
9437            correlated_lacks_compatible_installer: false,
9438        };
9439        let candidates = vec![SearchMatch {
9440            source_name: "winget".to_owned(),
9441            source_kind: SourceKind::PreIndexed,
9442            id: "Microsoft.PowerToys".to_owned(),
9443            name: "PowerToys".to_owned(),
9444            moniker: None,
9445            version: Some("0.98.1".to_owned()),
9446            channel: None,
9447            match_criteria: None,
9448        }];
9449
9450        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
9451        assert_eq!(correlated.id, "Microsoft.PowerToys");
9452    }
9453
9454    #[test]
9455    fn list_query_uses_available_lookup_for_tag_filters() {
9456        let query = ListQuery {
9457            tag: Some("terminal".to_owned()),
9458            ..ListQuery::default()
9459        };
9460
9461        assert!(list_query_needs_available_lookup(&query));
9462    }
9463
9464    #[test]
9465    fn list_tag_filter_requires_correlated_match() {
9466        let package = InstalledPackage {
9467            name: "PowerToys (Preview) x64".to_owned(),
9468            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
9469            installed_version: "0.98.1".to_owned(),
9470            publisher: None,
9471            scope: Some("Machine".to_owned()),
9472            installer_category: Some("exe".to_owned()),
9473            install_location: None,
9474            package_family_names: Vec::new(),
9475            product_codes: Vec::new(),
9476            upgrade_codes: Vec::new(),
9477            correlated: None,
9478            installed_version_canonical: false,
9479            correlated_requires_explicit_upgrade: false,
9480            correlated_lacks_compatible_installer: false,
9481        };
9482        let query = ListQuery {
9483            tag: Some("powertoys".to_owned()),
9484            ..ListQuery::default()
9485        };
9486
9487        assert!(!list_package_matches(&package, &query));
9488    }
9489
9490    #[test]
9491    fn list_lookup_ignores_display_count() {
9492        let query = ListQuery {
9493            query: Some("git".to_owned()),
9494            count: Some(5),
9495            ..ListQuery::default()
9496        };
9497
9498        let package_query = package_query_from_list_query(&query);
9499        assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
9500    }
9501
9502    #[test]
9503    fn correlation_tiebreaks_on_unnormalized_name_prefix() {
9504        // `Notepad++` and `Notepad--` both normalize to `notepad` (alphanumeric filter
9505        // strips the trailing punctuation). Without a tiebreaker, max_by_key returned
9506        // whichever candidate happened to come last in iteration order. The installed
9507        // display name's prefix decides the correct one.
9508        let installed = InstalledPackage {
9509            name: "Notepad++ (ARM 64-bit)".to_owned(),
9510            local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
9511            installed_version: "8.8.9".to_owned(),
9512            publisher: None,
9513            scope: Some("Machine".to_owned()),
9514            installer_category: Some("exe".to_owned()),
9515            install_location: None,
9516            package_family_names: Vec::new(),
9517            product_codes: Vec::new(),
9518            upgrade_codes: Vec::new(),
9519            correlated: None,
9520            installed_version_canonical: false,
9521            correlated_requires_explicit_upgrade: false,
9522            correlated_lacks_compatible_installer: false,
9523        };
9524        let candidates = vec![
9525            SearchMatch {
9526                source_name: "winget".to_owned(),
9527                source_kind: SourceKind::PreIndexed,
9528                id: "ndd.Notepad--".to_owned(),
9529                name: "Notepad--".to_owned(),
9530                moniker: None,
9531                version: Some("1.0.0".to_owned()),
9532                channel: None,
9533                match_criteria: None,
9534            },
9535            SearchMatch {
9536                source_name: "winget".to_owned(),
9537                source_kind: SourceKind::PreIndexed,
9538                id: "Notepad++.Notepad++".to_owned(),
9539                name: "Notepad++".to_owned(),
9540                moniker: None,
9541                version: Some("8.9.5".to_owned()),
9542                channel: None,
9543                match_criteria: None,
9544            },
9545        ];
9546
9547        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
9548        assert_eq!(correlated.id, "Notepad++.Notepad++");
9549    }
9550
9551    #[test]
9552    fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
9553        // Without the prefix bonus, the loose substring match picked up `Studio`
9554        // anywhere in the installed name — so `ZeroBrane.Studio` would outrank the
9555        // genuinely-related `Microsoft.DotNet.SDK.10` because both scored 700 and
9556        // sort order favored the alphabetically-later candidate.
9557        let installed = InstalledPackage {
9558            name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
9559            local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
9560            installed_version: "40.10.18029".to_owned(),
9561            publisher: None,
9562            scope: Some("Machine".to_owned()),
9563            installer_category: Some("msi".to_owned()),
9564            install_location: None,
9565            package_family_names: Vec::new(),
9566            product_codes: Vec::new(),
9567            upgrade_codes: Vec::new(),
9568            correlated: None,
9569            installed_version_canonical: false,
9570            correlated_requires_explicit_upgrade: false,
9571            correlated_lacks_compatible_installer: false,
9572        };
9573        let candidates = vec![
9574            SearchMatch {
9575                source_name: "winget".to_owned(),
9576                source_kind: SourceKind::PreIndexed,
9577                id: "ZeroBrane.Studio".to_owned(),
9578                name: "Studio".to_owned(),
9579                moniker: None,
9580                version: Some("1.0".to_owned()),
9581                channel: None,
9582                match_criteria: None,
9583            },
9584            SearchMatch {
9585                source_name: "winget".to_owned(),
9586                source_kind: SourceKind::PreIndexed,
9587                id: "BrickLink.Studio".to_owned(),
9588                name: "Studio".to_owned(),
9589                moniker: None,
9590                version: Some("1.0".to_owned()),
9591                channel: None,
9592                match_criteria: None,
9593            },
9594            SearchMatch {
9595                source_name: "winget".to_owned(),
9596                source_kind: SourceKind::PreIndexed,
9597                id: "Microsoft.DotNet.SDK.10".to_owned(),
9598                name: "Microsoft .NET SDK 10.0".to_owned(),
9599                moniker: None,
9600                version: Some("10.0.204".to_owned()),
9601                channel: None,
9602                match_criteria: None,
9603            },
9604        ];
9605
9606        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
9607        assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
9608    }
9609
9610    #[test]
9611    fn msix_packages_do_not_correlate_via_name() {
9612        // MSIX correlation must go through the v2 index's `pfns2` table — name
9613        // fallback is wrong because two MSIX packages can legitimately share a
9614        // display name without sharing identity (`Microsoft Edge Stable` MSIX
9615        // vs the catalog `Microsoft.Edge` MSI, `Notepad++` Store stub MSIX vs
9616        // the catalog Inno installer). Even when the names match exactly the
9617        // catalog `Microsoft.Teams` doesn't represent the same MSIX without a
9618        // PFN hit; that lookup happens in `correlate_installed_via_index`.
9619        let installed = InstalledPackage {
9620            name: "Microsoft Teams".to_owned(),
9621            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
9622            installed_version: "25290.205.4069.4894".to_owned(),
9623            publisher: None,
9624            scope: Some("User".to_owned()),
9625            installer_category: Some("msix".to_owned()),
9626            install_location: None,
9627            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
9628            product_codes: Vec::new(),
9629            upgrade_codes: Vec::new(),
9630            correlated: None,
9631            installed_version_canonical: false,
9632            correlated_requires_explicit_upgrade: false,
9633            correlated_lacks_compatible_installer: false,
9634        };
9635        let candidates = vec![SearchMatch {
9636            source_name: "winget".to_owned(),
9637            source_kind: SourceKind::PreIndexed,
9638            id: "Microsoft.Teams".to_owned(),
9639            name: "Microsoft Teams".to_owned(),
9640            moniker: None,
9641            version: Some("26106.1906.4665.7308".to_owned()),
9642            channel: None,
9643            match_criteria: None,
9644        }];
9645
9646        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
9647    }
9648
9649    #[test]
9650    fn name_fallback_refuses_ambiguous_winners() {
9651        // The user has Git installed (publisher "The Git Development Community"
9652        // — but publisher isn't in the scoring function). Two catalog packages
9653        // both expose name "Git": `Git.Git` and `Microsoft.Git`. Without
9654        // publisher disambiguation they score identically; winget refuses to
9655        // correlate (the install lists with empty Source). pinget must do the
9656        // same to avoid manufacturing an upgrade against the wrong catalog
9657        // package.
9658        let installed = InstalledPackage {
9659            name: "Git".to_owned(),
9660            local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
9661            installed_version: "2.53.0".to_owned(),
9662            publisher: Some("The Git Development Community".to_owned()),
9663            scope: Some("Machine".to_owned()),
9664            installer_category: Some("exe".to_owned()),
9665            install_location: None,
9666            package_family_names: Vec::new(),
9667            product_codes: Vec::new(),
9668            upgrade_codes: Vec::new(),
9669            correlated: None,
9670            installed_version_canonical: false,
9671            correlated_requires_explicit_upgrade: false,
9672            correlated_lacks_compatible_installer: false,
9673        };
9674        let candidates = vec![
9675            SearchMatch {
9676                source_name: "winget".to_owned(),
9677                source_kind: SourceKind::PreIndexed,
9678                id: "Git.Git".to_owned(),
9679                name: "Git".to_owned(),
9680                moniker: None,
9681                version: Some("2.54.0".to_owned()),
9682                channel: None,
9683                match_criteria: None,
9684            },
9685            SearchMatch {
9686                source_name: "winget".to_owned(),
9687                source_kind: SourceKind::PreIndexed,
9688                id: "Microsoft.Git".to_owned(),
9689                name: "Git".to_owned(),
9690                moniker: None,
9691                version: Some("2.53.0.0.7".to_owned()),
9692                channel: None,
9693                match_criteria: None,
9694            },
9695        ];
9696
9697        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
9698    }
9699
9700    #[test]
9701    fn msix_with_resource_string_name_does_not_correlate() {
9702        // Some MSIX entries have unresolved resource-string display names
9703        // (e.g. `ms-resource:appDisplayName`). Those shouldn't latch onto an
9704        // unrelated catalog package via the loose substring rule.
9705        let installed = InstalledPackage {
9706            name: "ms-resource:appDisplayName".to_owned(),
9707            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
9708            installed_version: "1.28.239.0".to_owned(),
9709            publisher: None,
9710            scope: Some("User".to_owned()),
9711            installer_category: Some("msix".to_owned()),
9712            install_location: None,
9713            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
9714            product_codes: Vec::new(),
9715            upgrade_codes: Vec::new(),
9716            correlated: None,
9717            installed_version_canonical: false,
9718            correlated_requires_explicit_upgrade: false,
9719            correlated_lacks_compatible_installer: false,
9720        };
9721        let candidates = vec![SearchMatch {
9722            source_name: "winget".to_owned(),
9723            source_kind: SourceKind::PreIndexed,
9724            id: "Microsoft.AppInstaller".to_owned(),
9725            name: "App Installer".to_owned(),
9726            moniker: None,
9727            version: Some("1.28.240.0".to_owned()),
9728            channel: None,
9729            match_criteria: None,
9730        }];
9731
9732        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
9733    }
9734
9735    #[test]
9736    fn strict_list_correlation_avoids_short_substring_matches() {
9737        let installed = InstalledPackage {
9738            name: "GitHub".to_owned(),
9739            local_id: r"ARP\User\X64\GitHub".to_owned(),
9740            installed_version: "1.0.0".to_owned(),
9741            publisher: None,
9742            scope: Some("User".to_owned()),
9743            installer_category: Some("exe".to_owned()),
9744            install_location: None,
9745            package_family_names: Vec::new(),
9746            product_codes: Vec::new(),
9747            upgrade_codes: Vec::new(),
9748            correlated: None,
9749            installed_version_canonical: false,
9750            correlated_requires_explicit_upgrade: false,
9751            correlated_lacks_compatible_installer: false,
9752        };
9753        let candidates = vec![SearchMatch {
9754            source_name: "winget".to_owned(),
9755            source_kind: SourceKind::PreIndexed,
9756            id: "Git.Git.PreRelease".to_owned(),
9757            name: "Git".to_owned(),
9758            moniker: None,
9759            version: Some("2.54.0".to_owned()),
9760            channel: None,
9761            match_criteria: None,
9762        }];
9763
9764        assert!(correlate_installed_package(&installed, &candidates, false).is_none());
9765    }
9766
9767    #[test]
9768    fn detects_available_upgrade_from_correlated_package() {
9769        let package = InstalledPackage {
9770            name: "AzCopy v10".to_owned(),
9771            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
9772            installed_version: "10.32.2".to_owned(),
9773            publisher: None,
9774            scope: Some("Machine".to_owned()),
9775            installer_category: Some("exe".to_owned()),
9776            install_location: None,
9777            package_family_names: Vec::new(),
9778            product_codes: Vec::new(),
9779            upgrade_codes: Vec::new(),
9780            correlated: Some(SearchMatch {
9781                source_name: "winget".to_owned(),
9782                source_kind: SourceKind::PreIndexed,
9783                id: "Microsoft.Azure.AZCopy.10".to_owned(),
9784                name: "AzCopy v10".to_owned(),
9785                moniker: None,
9786                version: Some("10.32.3".to_owned()),
9787                channel: None,
9788                match_criteria: None,
9789            }),
9790            installed_version_canonical: false,
9791            correlated_requires_explicit_upgrade: false,
9792            correlated_lacks_compatible_installer: false,
9793        };
9794
9795        assert!(installed_package_has_upgrade(&package));
9796    }
9797
9798    #[test]
9799    fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
9800        let package = InstalledPackage {
9801            name: "Example Tool".to_owned(),
9802            local_id: r"ARP\User\X64\ExampleTool".to_owned(),
9803            installed_version: "Unknown".to_owned(),
9804            publisher: None,
9805            scope: Some("User".to_owned()),
9806            installer_category: Some("exe".to_owned()),
9807            install_location: None,
9808            package_family_names: Vec::new(),
9809            product_codes: Vec::new(),
9810            upgrade_codes: Vec::new(),
9811            correlated: Some(SearchMatch {
9812                source_name: "winget".to_owned(),
9813                source_kind: SourceKind::PreIndexed,
9814                id: "Contoso.ExampleTool".to_owned(),
9815                name: "Example Tool".to_owned(),
9816                moniker: None,
9817                version: Some("2.0.0".to_owned()),
9818                channel: None,
9819                match_criteria: None,
9820            }),
9821            installed_version_canonical: false,
9822            correlated_requires_explicit_upgrade: false,
9823            correlated_lacks_compatible_installer: false,
9824        };
9825        let query = ListQuery {
9826            upgrade_only: true,
9827            include_unknown: true,
9828            ..Default::default()
9829        };
9830
9831        assert!(installed_package_matches_upgrade_filter(&package, &query));
9832        assert_eq!(
9833            list_match_from_installed(package).available_version.as_deref(),
9834            Some("2.0.0")
9835        );
9836    }
9837
9838    #[test]
9839    fn create_repair_list_query_includes_installed_selectors() {
9840        let request = RepairRequest {
9841            query: PackageQuery {
9842                query: Some("powertoys".to_owned()),
9843                id: Some("Microsoft.PowerToys".to_owned()),
9844                name: Some("PowerToys".to_owned()),
9845                moniker: Some("powertoys".to_owned()),
9846                source: Some("winget".to_owned()),
9847                exact: true,
9848                version: Some("0.98.1".to_owned()),
9849                install_scope: Some("Machine".to_owned()),
9850                ..PackageQuery::default()
9851            },
9852            manifest_path: None,
9853            product_code: Some("{GUID}".to_owned()),
9854            mode: InstallerMode::Silent,
9855            log_path: Some(PathBuf::from("repair.log")),
9856            accept_package_agreements: true,
9857            force: true,
9858            ignore_security_hash: true,
9859        };
9860
9861        let query = Repository::create_repair_list_query(&request);
9862        assert_eq!(query.query.as_deref(), Some("powertoys"));
9863        assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
9864        assert_eq!(query.name.as_deref(), Some("PowerToys"));
9865        assert_eq!(query.moniker.as_deref(), Some("powertoys"));
9866        assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
9867        assert_eq!(query.version.as_deref(), Some("0.98.1"));
9868        assert_eq!(query.source.as_deref(), Some("winget"));
9869        assert!(query.exact);
9870        assert_eq!(query.install_scope.as_deref(), Some("Machine"));
9871        assert_eq!(query.count, Some(100));
9872    }
9873
9874    #[test]
9875    fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
9876        let request = RepairRequest {
9877            query: PackageQuery {
9878                name: Some("PowerToys".to_owned()),
9879                source: Some("winget".to_owned()),
9880                locale: Some("en-US".to_owned()),
9881                installer_architecture: Some("x64".to_owned()),
9882                install_scope: Some("Machine".to_owned()),
9883                ..PackageQuery::default()
9884            },
9885            manifest_path: None,
9886            product_code: None,
9887            mode: InstallerMode::SilentWithProgress,
9888            log_path: Some(PathBuf::from("repair.log")),
9889            accept_package_agreements: true,
9890            force: false,
9891            ignore_security_hash: true,
9892        };
9893        let installed = ListMatch {
9894            name: "PowerToys".to_owned(),
9895            id: "Microsoft.PowerToys".to_owned(),
9896            local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
9897            installed_version: "0.98.1".to_owned(),
9898            available_version: Some("0.98.2".to_owned()),
9899            source_name: Some("winget".to_owned()),
9900            publisher: None,
9901            scope: Some("Machine".to_owned()),
9902            installer_category: Some("exe".to_owned()),
9903            install_location: None,
9904            package_family_names: Vec::new(),
9905            product_codes: Vec::new(),
9906            upgrade_codes: Vec::new(),
9907        };
9908
9909        let install_request = Repository::create_repair_install_request(&request, Some(&installed));
9910        assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
9911        assert!(install_request.query.query.is_none());
9912        assert!(install_request.query.name.is_none());
9913        assert!(install_request.query.moniker.is_none());
9914        assert_eq!(install_request.query.source.as_deref(), Some("winget"));
9915        assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
9916        assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
9917        assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
9918        assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
9919        assert!(install_request.query.exact);
9920        assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
9921        assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
9922        assert!(install_request.accept_package_agreements);
9923        assert!(install_request.force);
9924        assert!(install_request.ignore_security_hash);
9925    }
9926
9927    #[test]
9928    fn source_scoped_pin_operations_round_trip() {
9929        let app_root = temp_app_root("pins");
9930        let result = (|| -> Result<()> {
9931            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9932            repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
9933            repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
9934            repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
9935
9936            assert_eq!(repository.list_pins(None)?.len(), 3);
9937            assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
9938            assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
9939
9940            assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
9941            assert_eq!(repository.list_pins(None)?.len(), 2);
9942            assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
9943
9944            repository.reset_pins(Some("test"))?;
9945            let remaining = repository.list_pins(None)?;
9946            assert_eq!(remaining.len(), 1);
9947            assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
9948
9949            repository.reset_pins(None)?;
9950            assert!(repository.list_pins(None)?.is_empty());
9951            Ok(())
9952        })();
9953
9954        let _ = fs::remove_dir_all(&app_root);
9955        result.expect("pin round trip");
9956    }
9957
9958    #[test]
9959    fn pin_operations_work_with_packaged_pin_schema() {
9960        let app_root = temp_app_root("pins_packaged_schema");
9961        let result = (|| -> Result<()> {
9962            let db_path = pins_db_path(&app_root);
9963            if let Some(parent) = db_path.parent() {
9964                fs::create_dir_all(parent)?;
9965            }
9966
9967            let conn = Connection::open(&db_path)?;
9968            conn.execute_batch(
9969                "CREATE TABLE pin (
9970                    package_id TEXT NOT NULL,
9971                    source_id TEXT NOT NULL,
9972                    type INTEGER NOT NULL,
9973                    version TEXT NOT NULL,
9974                    PRIMARY KEY (package_id, source_id)
9975                )",
9976            )?;
9977            drop(conn);
9978
9979            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9980            repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
9981
9982            let pins = repository.list_pins(None)?;
9983            assert_eq!(pins.len(), 1);
9984            assert_eq!(pins[0].package_id, "Contoso.Tool");
9985            assert_eq!(pins[0].version, "1.2.*");
9986            assert_eq!(pins[0].source_id, "winget");
9987            assert_eq!(pins[0].pin_type, PinType::Gating);
9988
9989            let conn = Connection::open(&db_path)?;
9990            let stored_pin = conn.query_row(
9991                "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
9992                rusqlite::params!["Contoso.Tool", "winget"],
9993                |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
9994            )?;
9995            assert_eq!(stored_pin.0, 3);
9996            assert_eq!(stored_pin.1, "1.2.*");
9997
9998            Ok(())
9999        })();
10000
10001        let _ = fs::remove_dir_all(&app_root);
10002        result.expect("packaged pin schema round trip");
10003    }
10004
10005    #[test]
10006    fn version_pin_patterns_match_exact_and_prefix_values() {
10007        assert!(version_matches_pin_pattern("1.2.3", "*"));
10008        assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
10009        assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
10010        assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
10011        assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
10012    }
10013
10014    #[test]
10015    fn source_specific_pins_override_source_agnostic_pins() {
10016        let item = ListMatch {
10017            name: "Contoso Tool".to_owned(),
10018            id: "Contoso.Tool".to_owned(),
10019            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
10020            installed_version: "1.0.0".to_owned(),
10021            available_version: Some("2.0.0".to_owned()),
10022            source_name: Some("winget".to_owned()),
10023            publisher: None,
10024            scope: None,
10025            installer_category: None,
10026            install_location: None,
10027            package_family_names: Vec::new(),
10028            product_codes: Vec::new(),
10029            upgrade_codes: Vec::new(),
10030        };
10031        let pins = vec![
10032            PinRecord {
10033                package_id: "Contoso.Tool".to_owned(),
10034                version: "*".to_owned(),
10035                source_id: String::new(),
10036                pin_type: PinType::Blocking,
10037            },
10038            PinRecord {
10039                package_id: "Contoso.Tool".to_owned(),
10040                version: "2.0.*".to_owned(),
10041                source_id: "winget".to_owned(),
10042                pin_type: PinType::Pinning,
10043            },
10044        ];
10045
10046        assert!(!is_upgrade_blocked_by_pin(&item, &pins));
10047    }
10048
10049    #[test]
10050    fn blocking_pin_blocks_upgrade() {
10051        let item = ListMatch {
10052            name: "Contoso Tool".to_owned(),
10053            id: "Contoso.Tool".to_owned(),
10054            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
10055            installed_version: "1.0.0".to_owned(),
10056            available_version: Some("2.0.0".to_owned()),
10057            source_name: Some("winget".to_owned()),
10058            publisher: None,
10059            scope: None,
10060            installer_category: None,
10061            install_location: None,
10062            package_family_names: Vec::new(),
10063            product_codes: Vec::new(),
10064            upgrade_codes: Vec::new(),
10065        };
10066        let pins = vec![PinRecord {
10067            package_id: "Contoso.Tool".to_owned(),
10068            version: "*".to_owned(),
10069            source_id: "winget".to_owned(),
10070            pin_type: PinType::Blocking,
10071        }];
10072
10073        assert!(is_upgrade_blocked_by_pin(&item, &pins));
10074    }
10075
10076    #[cfg(windows)]
10077    #[test]
10078    fn parses_msix_package_full_name_into_version_and_family() {
10079        let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
10080            .expect("package metadata");
10081
10082        assert_eq!(parsed.version, "0.98.1.0");
10083        assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
10084    }
10085
10086    #[cfg(windows)]
10087    #[test]
10088    fn recognizes_windows_system_paths() {
10089        assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
10090        assert!(!is_windows_system_path(
10091            r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
10092        ));
10093    }
10094
10095    #[test]
10096    fn search_ranking_prefers_exact_name_over_tag_match() {
10097        let query = PackageQuery {
10098            query: Some("PowerToys".to_owned()),
10099            ..Default::default()
10100        };
10101        let exact_name = SearchMatch {
10102            source_name: "winget".to_owned(),
10103            source_kind: SourceKind::PreIndexed,
10104            id: "Microsoft.PowerToys".to_owned(),
10105            name: "PowerToys".to_owned(),
10106            moniker: Some("powertoys".to_owned()),
10107            version: Some("0.98.1".to_owned()),
10108            channel: None,
10109            match_criteria: Some("Moniker: powertoys".to_owned()),
10110        };
10111        let tag_match = SearchMatch {
10112            source_name: "winget".to_owned(),
10113            source_kind: SourceKind::PreIndexed,
10114            id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
10115            name: "QR Codes for Command Palette".to_owned(),
10116            moniker: None,
10117            version: Some("0.4.0.0".to_owned()),
10118            channel: None,
10119            match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
10120        };
10121
10122        assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
10123    }
10124
10125    #[test]
10126    fn select_best_text_match_prefers_exact_tag_value() {
10127        let values = vec![
10128            "microsoft-powertoys".to_owned(),
10129            "powertoys-run".to_owned(),
10130            "powertoys".to_owned(),
10131        ];
10132
10133        assert_eq!(
10134            select_best_text_match(values, "PowerToys", false).as_deref(),
10135            Some("powertoys")
10136        );
10137    }
10138
10139    #[test]
10140    fn search_match_criteria_is_blank_for_name_match() {
10141        let query = PackageQuery {
10142            query: Some("PowerToys".to_owned()),
10143            ..Default::default()
10144        };
10145
10146        let result = infer_match_criteria(
10147            "Microsoft.PowerToys",
10148            "PowerToys",
10149            Some("powertoys"),
10150            &query,
10151            SearchSemantics::Many,
10152            |_| Ok(Some("powertoys".to_owned())),
10153            |_| Ok(None),
10154        )
10155        .expect("match criteria");
10156
10157        assert_eq!(result, None);
10158    }
10159
10160    #[test]
10161    fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
10162        let query = PackageQuery {
10163            query: Some("PowerToys".to_owned()),
10164            ..Default::default()
10165        };
10166        let unknown_version = SearchMatch {
10167            source_name: "msstore".to_owned(),
10168            source_kind: SourceKind::Rest,
10169            id: "XP89DCGQ3K6VLD".to_owned(),
10170            name: "Microsoft PowerToys".to_owned(),
10171            moniker: None,
10172            version: Some("Unknown".to_owned()),
10173            channel: None,
10174            match_criteria: None,
10175        };
10176        let tag_match = SearchMatch {
10177            source_name: "winget".to_owned(),
10178            source_kind: SourceKind::PreIndexed,
10179            id: "Riri.QRCodeforCmdPal".to_owned(),
10180            name: "QR Code for CmdPal".to_owned(),
10181            moniker: None,
10182            version: Some("0.0.1.0".to_owned()),
10183            channel: None,
10184            match_criteria: Some("Tag: powertoys".to_owned()),
10185        };
10186
10187        assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
10188    }
10189
10190    #[test]
10191    fn search_source_fetch_results_ignore_small_display_count() {
10192        let query = PackageQuery {
10193            count: Some(5),
10194            ..Default::default()
10195        };
10196
10197        assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
10198        assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
10199    }
10200
10201    #[test]
10202    fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
10203        let query = PackageQuery {
10204            query: Some("PowerToys".to_owned()),
10205            ..Default::default()
10206        };
10207        let prefix_name = SearchMatch {
10208            source_name: "winget".to_owned(),
10209            source_kind: SourceKind::PreIndexed,
10210            id: "advaith.CurrencyConverterPowerToys".to_owned(),
10211            name: "PowerToys-Run-Currency-Converter".to_owned(),
10212            moniker: None,
10213            version: Some("1.5.4".to_owned()),
10214            channel: None,
10215            match_criteria: None,
10216        };
10217        let exact_tag = SearchMatch {
10218            source_name: "winget".to_owned(),
10219            source_kind: SourceKind::PreIndexed,
10220            id: "Riri.QRCodeforCmdPal".to_owned(),
10221            name: "QR Code for CmdPal".to_owned(),
10222            moniker: None,
10223            version: Some("0.0.1.0".to_owned()),
10224            channel: None,
10225            match_criteria: Some("Tag: powertoys".to_owned()),
10226        };
10227
10228        assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
10229    }
10230
10231    #[test]
10232    fn list_sort_prefers_main_package_then_sparse_app() {
10233        let main = InstalledPackage {
10234            name: "PowerToys (Preview) x64".to_owned(),
10235            local_id: r"ARP\User\X64\PowerToys".to_owned(),
10236            installed_version: "0.98.1".to_owned(),
10237            publisher: None,
10238            scope: Some("User".to_owned()),
10239            installer_category: Some("exe".to_owned()),
10240            install_location: None,
10241            package_family_names: Vec::new(),
10242            product_codes: Vec::new(),
10243            upgrade_codes: Vec::new(),
10244            correlated: None,
10245            installed_version_canonical: false,
10246            correlated_requires_explicit_upgrade: false,
10247            correlated_lacks_compatible_installer: false,
10248        };
10249        let sparse = InstalledPackage {
10250            name: "PowerToys.SparseApp".to_owned(),
10251            local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
10252            installed_version: "0.98.1.0".to_owned(),
10253            publisher: None,
10254            scope: Some("User".to_owned()),
10255            installer_category: Some("msix".to_owned()),
10256            install_location: None,
10257            package_family_names: Vec::new(),
10258            product_codes: Vec::new(),
10259            upgrade_codes: Vec::new(),
10260            correlated: None,
10261            installed_version_canonical: false,
10262            correlated_requires_explicit_upgrade: false,
10263            correlated_lacks_compatible_installer: false,
10264        };
10265        let extension = InstalledPackage {
10266            name: "PowerToys FileLocksmith Context Menu".to_owned(),
10267            local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
10268            installed_version: "0.98.1.0".to_owned(),
10269            publisher: None,
10270            scope: Some("User".to_owned()),
10271            installer_category: Some("msix".to_owned()),
10272            install_location: None,
10273            package_family_names: Vec::new(),
10274            product_codes: Vec::new(),
10275            upgrade_codes: Vec::new(),
10276            correlated: None,
10277            installed_version_canonical: false,
10278            correlated_requires_explicit_upgrade: false,
10279            correlated_lacks_compatible_installer: false,
10280        };
10281
10282        assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
10283        assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
10284    }
10285
10286    #[test]
10287    #[cfg(windows)]
10288    fn arp_subkey_name_extracts_registry_subkey() {
10289        assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
10290        assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
10291    }
10292
10293    #[test]
10294    #[cfg(windows)]
10295    fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
10296        let installed = ListMatch {
10297            name: "ShareX".to_owned(),
10298            id: "ShareX.ShareX".to_owned(),
10299            local_id: r"ARP\Machine\X64\ShareX".to_owned(),
10300            installed_version: "19.0.2".to_owned(),
10301            available_version: None,
10302            source_name: Some("winget".to_owned()),
10303            publisher: Some("ShareX Team".to_owned()),
10304            scope: Some("Machine".to_owned()),
10305            installer_category: Some("exe".to_owned()),
10306            install_location: Some(r"C:\Program Files\ShareX".to_owned()),
10307            package_family_names: Vec::new(),
10308            product_codes: Vec::new(),
10309            upgrade_codes: Vec::new(),
10310        };
10311
10312        assert!(registry_entry_matches_installed_package(
10313            "ShareX", "ShareX", None, &installed
10314        ));
10315        assert!(!registry_entry_matches_installed_package(
10316            "ShareX.ShareX",
10317            "ShareX.ShareX",
10318            None,
10319            &installed
10320        ));
10321    }
10322
10323    #[test]
10324    #[cfg(windows)]
10325    fn build_uninstall_command_only_appends_silent_flag_when_needed() {
10326        assert_eq!(
10327            r#""C:\Program Files\ShareX\unins000.exe" /S"#,
10328            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
10329        );
10330        assert_eq!(
10331            r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
10332            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
10333        );
10334        assert_eq!(
10335            r#""C:\Program Files\ShareX\unins000.exe""#,
10336            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
10337        );
10338        assert_eq!(
10339            "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
10340            build_uninstall_command(
10341                "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
10342                true,
10343                false,
10344            )
10345        );
10346    }
10347
10348    #[test]
10349    #[cfg(windows)]
10350    fn build_winget_uninstall_arguments_prefers_correlated_identity() {
10351        let installed = ListMatch {
10352            name: "lazygit".to_owned(),
10353            id: "JesseDuffield.lazygit".to_owned(),
10354            local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
10355            installed_version: "0.61.1".to_owned(),
10356            available_version: None,
10357            source_name: Some("winget".to_owned()),
10358            publisher: Some("Jesse Duffield".to_owned()),
10359            scope: Some("User".to_owned()),
10360            installer_category: Some("exe".to_owned()),
10361            install_location: Some(
10362                r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
10363            ),
10364            package_family_names: Vec::new(),
10365            product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
10366            upgrade_codes: Vec::new(),
10367        };
10368        let mut request = UninstallRequest::new(PackageQuery {
10369            id: Some("JesseDuffield.lazygit".to_owned()),
10370            install_scope: Some("user".to_owned()),
10371            ..PackageQuery::default()
10372        });
10373        request.mode = InstallerMode::Silent;
10374
10375        assert_eq!(
10376            Some(vec![
10377                "uninstall".to_owned(),
10378                "--id".to_owned(),
10379                "JesseDuffield.lazygit".to_owned(),
10380                "--exact".to_owned(),
10381                "--disable-interactivity".to_owned(),
10382                "--source".to_owned(),
10383                "winget".to_owned(),
10384                "--scope".to_owned(),
10385                "user".to_owned(),
10386                "--silent".to_owned(),
10387            ]),
10388            build_winget_uninstall_arguments(&installed, &request)
10389        );
10390
10391        assert_eq!(
10392            Some(vec![
10393                "uninstall".to_owned(),
10394                "--id".to_owned(),
10395                "JesseDuffield.lazygit".to_owned(),
10396                "--exact".to_owned(),
10397                "--disable-interactivity".to_owned(),
10398                "--source".to_owned(),
10399                "winget".to_owned(),
10400                "--silent".to_owned(),
10401            ]),
10402            build_winget_uninstall_arguments_with_scope(&installed, &request, false)
10403        );
10404    }
10405
10406    fn version_entry(version: &str, arp_min: Option<&str>, arp_max: Option<&str>) -> PackageVersionDataEntry {
10407        PackageVersionDataEntry {
10408            version: version.to_owned(),
10409            manifest_relative_path: format!("manifests/x/{version}"),
10410            manifest_hash: "00".repeat(32),
10411            arp_min_version: arp_min.map(str::to_owned),
10412            arp_max_version: arp_max.map(str::to_owned),
10413        }
10414    }
10415
10416    #[test]
10417    fn map_arp_version_returns_catalog_version_inside_range() {
10418        // Real winget data: .NET SDK 10.0.108 declares its ARP DisplayVersion is
10419        // `10.1.826.23019`. compare_version on that against the catalog Version
10420        // `10.0.108` claims installed > available, so without this mapping the
10421        // upgrade was silently dropped.
10422        let entries = vec![
10423            version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
10424            version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
10425            version_entry("10.0.107", Some("10.1.726.21808"), Some("10.1.726.21808")),
10426        ];
10427
10428        assert_eq!(
10429            map_arp_version_to_catalog(&entries, "10.1.826.23019").as_deref(),
10430            Some("10.0.108")
10431        );
10432    }
10433
10434    #[test]
10435    fn map_arp_version_returns_none_when_no_range_matches() {
10436        // VS-installed .NET SDK reports ARP DisplayVersion `40.10.18029` which
10437        // doesn't sit inside any aMiV..aMaV bucket. Caller must keep the
10438        // original installed version (and accept that no upgrade can be
10439        // computed) — same as winget's behavior.
10440        let entries = vec![
10441            version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
10442            version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
10443        ];
10444
10445        assert!(map_arp_version_to_catalog(&entries, "40.10.18029").is_none());
10446    }
10447
10448    #[test]
10449    fn map_arp_version_skips_entries_missing_arp_bounds() {
10450        // Older catalog packages predate AppsAndFeaturesEntries and have no
10451        // aMiV/aMaV. Those entries must be ignored — not silently treated as
10452        // an unbounded range.
10453        let entries = vec![
10454            version_entry("9.0.300", None, None),
10455            version_entry("9.0.117", Some("9.1.1726.23010"), Some("9.1.1726.23010")),
10456        ];
10457
10458        assert_eq!(
10459            map_arp_version_to_catalog(&entries, "9.1.1726.23010").as_deref(),
10460            Some("9.0.117")
10461        );
10462        assert!(map_arp_version_to_catalog(&entries, "1.0.0").is_none());
10463    }
10464
10465    #[test]
10466    fn map_arp_version_handles_inclusive_range_endpoints() {
10467        // aMiV/aMaV are inclusive bounds — a version equal to either endpoint
10468        // must still map.
10469        let entries = vec![version_entry("3.14.5", Some("3.14.5.0"), Some("3.14.5.999"))];
10470
10471        assert_eq!(
10472            map_arp_version_to_catalog(&entries, "3.14.5.0").as_deref(),
10473            Some("3.14.5")
10474        );
10475        assert_eq!(
10476            map_arp_version_to_catalog(&entries, "3.14.5.999").as_deref(),
10477            Some("3.14.5")
10478        );
10479        assert_eq!(
10480            map_arp_version_to_catalog(&entries, "3.14.5.500").as_deref(),
10481            Some("3.14.5")
10482        );
10483    }
10484
10485    #[test]
10486    fn map_arp_version_ignores_unknown_marker() {
10487        // collect_installed_packages writes the literal "Unknown" when ARP has
10488        // no DisplayVersion. Trying to map that would falsely match the first
10489        // entry whose aMiV..aMaV string-compares to include "Unknown".
10490        let entries = vec![version_entry("1.0.0", Some("1.0.0"), Some("1.0.0"))];
10491
10492        assert!(map_arp_version_to_catalog(&entries, "Unknown").is_none());
10493        assert!(map_arp_version_to_catalog(&entries, "").is_none());
10494    }
10495
10496    #[test]
10497    fn latest_arp_anchored_skips_internal_versions() {
10498        // Microsoft.WindowsAppRuntime.1.8 publishes both an internal build
10499        // version (`8000.836.2153.0`, no ARP bounds) and user-facing versions
10500        // (`1.8.6`, `1.8.5`, …). The internal row shouldn't win — comparing
10501        // it against an installed canonical of `1.8.6` would falsely report
10502        // an upgrade. Returns the highest anchored Version instead.
10503        let entries = vec![
10504            version_entry("8000.836.2153.0", None, None),
10505            version_entry("1.8.6", Some("8000.806.2252.0"), Some("8000.806.2252.0")),
10506            version_entry("1.8.5", Some("8000.770.947.0"), Some("8000.770.947.0")),
10507            version_entry("1.8.0", Some("8000.616.304.0"), Some("8000.616.304.0")),
10508        ];
10509
10510        assert_eq!(latest_arp_anchored_version(&entries).as_deref(), Some("1.8.6"));
10511    }
10512
10513    #[test]
10514    fn latest_arp_anchored_returns_none_without_bounds() {
10515        // App Installer's versionData has no aMiV/aMaV — every version is a
10516        // user-facing MSIX Version. Caller must fall back to
10517        // `packages.latest_version` in this case.
10518        let entries = vec![
10519            version_entry("1.28.240.0", None, None),
10520            version_entry("1.27.470.0", None, None),
10521        ];
10522
10523        assert!(latest_arp_anchored_version(&entries).is_none());
10524    }
10525
10526    #[test]
10527    fn list_match_from_installed_prefers_correlated_id_and_source_name() {
10528        let package = InstalledPackage {
10529            name: "AzCopy v10".to_owned(),
10530            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
10531            installed_version: "10.32.2".to_owned(),
10532            publisher: Some("Microsoft".to_owned()),
10533            scope: Some("Machine".to_owned()),
10534            installer_category: Some("exe".to_owned()),
10535            install_location: None,
10536            package_family_names: Vec::new(),
10537            product_codes: Vec::new(),
10538            upgrade_codes: Vec::new(),
10539            correlated: Some(SearchMatch {
10540                source_name: "winget".to_owned(),
10541                source_kind: SourceKind::PreIndexed,
10542                id: "Microsoft.Azure.AZCopy.10".to_owned(),
10543                name: "AzCopy v10".to_owned(),
10544                moniker: None,
10545                version: Some("10.32.3".to_owned()),
10546                channel: None,
10547                match_criteria: None,
10548            }),
10549            installed_version_canonical: false,
10550            correlated_requires_explicit_upgrade: false,
10551            correlated_lacks_compatible_installer: false,
10552        };
10553
10554        let item = list_match_from_installed(package);
10555
10556        assert_eq!(item.id, "Microsoft.Azure.AZCopy.10");
10557        assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy");
10558        assert_eq!(item.source_name.as_deref(), Some("winget"));
10559        assert_eq!(item.available_version.as_deref(), Some("10.32.3"));
10560    }
10561
10562    #[test]
10563    fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() {
10564        let package = InstalledPackage {
10565            name: "Atlassian CLI".to_owned(),
10566            local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
10567            installed_version: "1.3.18-stable".to_owned(),
10568            publisher: Some("Atlassian".to_owned()),
10569            scope: Some("User".to_owned()),
10570            installer_category: Some("exe".to_owned()),
10571            install_location: None,
10572            package_family_names: Vec::new(),
10573            product_codes: Vec::new(),
10574            upgrade_codes: Vec::new(),
10575            correlated: Some(SearchMatch {
10576                source_name: "winget".to_owned(),
10577                source_kind: SourceKind::PreIndexed,
10578                id: "Atlassian.AtlassianCLI".to_owned(),
10579                name: "Atlassian CLI".to_owned(),
10580                moniker: None,
10581                version: Some("1.3.18-stable".to_owned()),
10582                channel: None,
10583                match_criteria: Some("ProductCode".to_owned()),
10584            }),
10585            installed_version_canonical: false,
10586            correlated_requires_explicit_upgrade: false,
10587            correlated_lacks_compatible_installer: false,
10588        };
10589
10590        let item = list_match_from_installed(package);
10591
10592        assert_eq!(item.id, "Atlassian.AtlassianCLI");
10593        assert_eq!(
10594            item.local_id,
10595            r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe"
10596        );
10597        assert_ne!(item.id, item.local_id);
10598        assert_eq!(item.source_name.as_deref(), Some("winget"));
10599    }
10600
10601    #[test]
10602    fn list_response_serialization_keeps_pascal_case_fields() {
10603        let response = ListResponse {
10604            matches: vec![ListMatch {
10605                name: "PowerToys".to_owned(),
10606                id: "Microsoft.PowerToys".to_owned(),
10607                local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
10608                installed_version: "0.98.1".to_owned(),
10609                available_version: Some("0.99.0".to_owned()),
10610                source_name: Some("winget".to_owned()),
10611                publisher: Some("Microsoft".to_owned()),
10612                scope: Some("Machine".to_owned()),
10613                installer_category: Some("msi".to_owned()),
10614                install_location: None,
10615                package_family_names: Vec::new(),
10616                product_codes: Vec::new(),
10617                upgrade_codes: Vec::new(),
10618            }],
10619            warnings: Vec::new(),
10620            truncated: false,
10621        };
10622
10623        let value = serde_json::to_value(&response).expect("serialize list response");
10624        let match_value = &value["Matches"][0];
10625
10626        assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys");
10627        assert_eq!(match_value["InstalledVersion"], "0.98.1");
10628        assert_eq!(match_value["AvailableVersion"], "0.99.0");
10629        assert_eq!(match_value["SourceName"], "winget");
10630        assert!(match_value.get("local_id").is_none());
10631        assert!(match_value.get("installed_version").is_none());
10632        assert!(match_value.get("available_version").is_none());
10633        assert!(match_value.get("source_name").is_none());
10634    }
10635
10636    #[test]
10637    fn search_response_serialization_preserves_minimum_pascal_case_shape() {
10638        let response = SearchResponse {
10639            matches: vec![SearchMatch {
10640                source_name: "msstore".to_owned(),
10641                source_kind: SourceKind::Rest,
10642                id: "9WZDNCRFJBMP".to_owned(),
10643                name: "Microsoft To Do".to_owned(),
10644                moniker: None,
10645                version: Some("2.123.456.0".to_owned()),
10646                channel: None,
10647                match_criteria: None,
10648            }],
10649            warnings: Vec::new(),
10650            truncated: false,
10651        };
10652
10653        let value = serde_json::to_value(&response).expect("serialize search response");
10654        let match_value = &value["Matches"][0];
10655
10656        assert_eq!(match_value["Name"], "Microsoft To Do");
10657        assert_eq!(match_value["Id"], "9WZDNCRFJBMP");
10658        assert_eq!(match_value["Version"], "2.123.456.0");
10659        assert_eq!(match_value["SourceName"], "msstore");
10660        assert!(match_value.get("source_name").is_none());
10661    }
10662
10663    #[test]
10664    fn versions_result_serialization_keeps_pascal_case_fields() {
10665        let result = VersionsResult {
10666            package: SearchMatch {
10667                source_name: "winget".to_owned(),
10668                source_kind: SourceKind::PreIndexed,
10669                id: "Microsoft.PowerToys".to_owned(),
10670                name: "PowerToys".to_owned(),
10671                moniker: None,
10672                version: Some("0.99.0".to_owned()),
10673                channel: None,
10674                match_criteria: None,
10675            },
10676            versions: vec![
10677                VersionKey {
10678                    version: "0.99.0".to_owned(),
10679                    channel: "stable".to_owned(),
10680                },
10681                VersionKey {
10682                    version: "0.100.0-preview".to_owned(),
10683                    channel: "preview".to_owned(),
10684                },
10685            ],
10686            warnings: Vec::new(),
10687        };
10688
10689        let value = serde_json::to_value(&result).expect("serialize versions result");
10690
10691        assert_eq!(value["Versions"][0]["Version"], "0.99.0");
10692        assert_eq!(value["Versions"][0]["Channel"], "stable");
10693        assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview");
10694        assert_eq!(value["Versions"][1]["Channel"], "preview");
10695        assert!(value.get("versions").is_none());
10696    }
10697
10698    #[test]
10699    fn version_data_parses_arp_bounds_from_winget_payload() {
10700        // Sanity check that aMiV/aMaV deserialize from a real-world
10701        // versionData.mszyml payload shape. Without `default`, packages
10702        // predating AppsAndFeaturesEntries would fail to parse.
10703        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";
10704
10705        let document = serde_yaml::from_str::<PackageVersionDataDocument>(payload).expect("parse versionData.mszyml");
10706        assert_eq!(document.versions.len(), 2);
10707        assert_eq!(document.versions[0].arp_min_version.as_deref(), Some("10.3.26.23102"));
10708        assert_eq!(document.versions[0].arp_max_version.as_deref(), Some("10.3.26.23102"));
10709        assert!(document.versions[1].arp_min_version.is_none());
10710        assert!(document.versions[1].arp_max_version.is_none());
10711    }
10712
10713    #[test]
10714    fn msix_resource_string_name_resolves_to_catalog_name() {
10715        // App Installer's MSIX manifest stores DisplayName as
10716        // `ms-resource:appDisplayName`. Once we correlate it via PFN, we
10717        // know the catalog calls it `App Installer` — show that instead of
10718        // the unresolved placeholder, matching winget's output.
10719        let mut package = InstalledPackage {
10720            name: "ms-resource:appDisplayName".to_owned(),
10721            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
10722            installed_version: "1.28.239.0".to_owned(),
10723            publisher: None,
10724            scope: Some("User".to_owned()),
10725            installer_category: Some("msix".to_owned()),
10726            install_location: None,
10727            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
10728            product_codes: Vec::new(),
10729            upgrade_codes: Vec::new(),
10730            correlated: Some(SearchMatch {
10731                source_name: "winget".to_owned(),
10732                source_kind: SourceKind::PreIndexed,
10733                id: "Microsoft.AppInstaller".to_owned(),
10734                name: "App Installer".to_owned(),
10735                moniker: None,
10736                version: Some("1.28.240.0".to_owned()),
10737                channel: None,
10738                match_criteria: Some("PackageFamilyName".to_owned()),
10739            }),
10740            installed_version_canonical: false,
10741            correlated_requires_explicit_upgrade: false,
10742            correlated_lacks_compatible_installer: false,
10743        };
10744        apply_msix_resource_string_name_fix(&mut package);
10745        assert_eq!(package.name, "App Installer");
10746    }
10747
10748    #[test]
10749    fn msix_resource_string_fix_is_a_noop_when_not_msix() {
10750        // ARP rows can also carry unusual display names; the fix is gated
10751        // on the local_id prefix so a `ms-resource:` literal that somehow
10752        // appears in an ARP DisplayName (extremely unlikely, but possible)
10753        // doesn't get silently rewritten.
10754        let mut package = InstalledPackage {
10755            name: "ms-resource:appDisplayName".to_owned(),
10756            local_id: r"ARP\Machine\X64\{deadbeef}".to_owned(),
10757            installed_version: "1.0".to_owned(),
10758            publisher: None,
10759            scope: Some("Machine".to_owned()),
10760            installer_category: Some("msi".to_owned()),
10761            install_location: None,
10762            package_family_names: Vec::new(),
10763            product_codes: Vec::new(),
10764            upgrade_codes: Vec::new(),
10765            correlated: Some(SearchMatch {
10766                source_name: "winget".to_owned(),
10767                source_kind: SourceKind::PreIndexed,
10768                id: "Some.Package".to_owned(),
10769                name: "Should Not Apply".to_owned(),
10770                moniker: None,
10771                version: Some("1.0".to_owned()),
10772                channel: None,
10773                match_criteria: None,
10774            }),
10775            installed_version_canonical: false,
10776            correlated_requires_explicit_upgrade: false,
10777            correlated_lacks_compatible_installer: false,
10778        };
10779        apply_msix_resource_string_name_fix(&mut package);
10780        assert_eq!(package.name, "ms-resource:appDisplayName");
10781    }
10782
10783    #[test]
10784    fn msix_resource_string_fix_skips_resolved_names() {
10785        // The vast majority of MSIX entries have already-resolved names
10786        // (`Microsoft Teams`, `Notepad++`, etc.). The fix must not touch
10787        // those — `meta.name` and the installed name may legitimately
10788        // differ (`Microsoft Teams` vs `Microsoft Teams (work or school)`).
10789        let mut package = InstalledPackage {
10790            name: "Microsoft Teams".to_owned(),
10791            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
10792            installed_version: "25290.205.4069.4894".to_owned(),
10793            publisher: None,
10794            scope: Some("User".to_owned()),
10795            installer_category: Some("msix".to_owned()),
10796            install_location: None,
10797            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
10798            product_codes: Vec::new(),
10799            upgrade_codes: Vec::new(),
10800            correlated: Some(SearchMatch {
10801                source_name: "winget".to_owned(),
10802                source_kind: SourceKind::PreIndexed,
10803                id: "Microsoft.Teams".to_owned(),
10804                name: "Microsoft Teams Catalog Name".to_owned(),
10805                moniker: None,
10806                version: Some("26106.1906.4665.7308".to_owned()),
10807                channel: None,
10808                match_criteria: Some("PackageFamilyName".to_owned()),
10809            }),
10810            installed_version_canonical: false,
10811            correlated_requires_explicit_upgrade: false,
10812            correlated_lacks_compatible_installer: false,
10813        };
10814        apply_msix_resource_string_name_fix(&mut package);
10815        assert_eq!(package.name, "Microsoft Teams");
10816    }
10817
10818    #[cfg(windows)]
10819    #[test]
10820    fn unflip_packed_guid_reverses_msi_installer_packing() {
10821        // The MSI Installer hive packs GUIDs by char-reversing each of the 11
10822        // chunks (8/4/4/2*8). This is the encoding used inside
10823        // `HKLM\SOFTWARE\Classes\Installer\UpgradeCodes\<key>` subkey names
10824        // and their value names. Verified against the user's installed
10825        // Node.js ProductCode `{9292CBD9-...}` whose packed form (from the
10826        // live registry) is `9DBC2929593B4D2488740C8E00C4F652`.
10827        assert_eq!(
10828            unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F652").as_deref(),
10829            Some("{9292cbd9-b395-42d4-8847-c0e8004c6f25}")
10830        );
10831        // And the reverse case — Node.js UpgradeCode `{47c07a3a-...}` packed
10832        // form `A3A70C74FE2431248AD5F8A59570C782`.
10833        assert_eq!(
10834            unflip_packed_guid("A3A70C74FE2431248AD5F8A59570C782").as_deref(),
10835            Some("{47c07a3a-42ef-4213-a85d-8f5a59077c28}")
10836        );
10837        // Reject non-32-char or non-hex inputs.
10838        assert!(unflip_packed_guid("nothex").is_none());
10839        assert!(unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F65").is_none()); // 31 chars
10840        assert!(unflip_packed_guid("ZZZZZZZZ593B4D2488740C8E00C4F652").is_none());
10841    }
10842
10843    fn installed_with_correlated(id: &str, installed_version: &str, canonical: bool) -> InstalledPackage {
10844        InstalledPackage {
10845            name: format!("{id} install"),
10846            local_id: format!(r"ARP\Machine\X64\{id}"),
10847            installed_version: installed_version.to_owned(),
10848            publisher: None,
10849            scope: Some("Machine".to_owned()),
10850            installer_category: Some("msi".to_owned()),
10851            install_location: None,
10852            package_family_names: Vec::new(),
10853            product_codes: Vec::new(),
10854            upgrade_codes: Vec::new(),
10855            correlated: Some(SearchMatch {
10856                source_name: "winget".to_owned(),
10857                source_kind: SourceKind::PreIndexed,
10858                id: id.to_owned(),
10859                name: id.to_owned(),
10860                moniker: None,
10861                version: Some("99.0.0".to_owned()),
10862                channel: None,
10863                match_criteria: None,
10864            }),
10865            installed_version_canonical: canonical,
10866            correlated_requires_explicit_upgrade: false,
10867            correlated_lacks_compatible_installer: false,
10868        }
10869    }
10870
10871    #[test]
10872    fn dedupe_keeps_canonical_row_over_raw_arp_row() {
10873        // Two installed entries correlate to the same catalog id. One had its
10874        // ARP version remapped to a catalog Version via aMiV/aMaV (canonical);
10875        // the other did not (e.g. VS-installed .NET SDK whose ARP
10876        // `40.10.18029` doesn't fit any bucket). Without the canonical
10877        // preference, `compare_version` says `40.x.x.x > 10.0.108` so the
10878        // wrong row wins and the upgrade silently disappears.
10879        let raw = installed_with_correlated("Microsoft.DotNet.SDK.10", "40.10.18029", false);
10880        let canonical = installed_with_correlated("Microsoft.DotNet.SDK.10", "10.0.108", true);
10881
10882        let result = dedupe_correlated_for_upgrade(vec![raw, canonical]);
10883        assert_eq!(result.len(), 1);
10884        assert_eq!(result[0].installed_version, "10.0.108");
10885        assert!(result[0].installed_version_canonical);
10886    }
10887
10888    #[test]
10889    fn dedupe_keeps_highest_among_canonical_rows() {
10890        let lower = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.7", true);
10891        let higher = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.9", true);
10892
10893        let result = dedupe_correlated_for_upgrade(vec![lower, higher]);
10894        assert_eq!(result.len(), 1);
10895        assert_eq!(result[0].installed_version, "1.7.9");
10896    }
10897
10898    #[test]
10899    fn dedupe_leaves_uncorrelated_rows_alone() {
10900        // Uncorrelated entries don't reach upgrade output anyway (the
10901        // upgrade filter requires correlation), but the dedupe pass must not
10902        // drop them — `pinget list` could share this code path in the future.
10903        let uncorrelated = InstalledPackage {
10904            name: "Foo".to_owned(),
10905            local_id: r"ARP\Machine\X64\Foo".to_owned(),
10906            installed_version: "1.0".to_owned(),
10907            publisher: None,
10908            scope: Some("Machine".to_owned()),
10909            installer_category: Some("exe".to_owned()),
10910            install_location: None,
10911            package_family_names: Vec::new(),
10912            product_codes: Vec::new(),
10913            upgrade_codes: Vec::new(),
10914            correlated: None,
10915            installed_version_canonical: false,
10916            correlated_requires_explicit_upgrade: false,
10917            correlated_lacks_compatible_installer: false,
10918        };
10919        let result = dedupe_correlated_for_upgrade(vec![uncorrelated]);
10920        assert_eq!(result.len(), 1);
10921    }
10922}